Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions apps/web/src/lab/worst-case-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,43 @@ function row(overrides: Partial<SpanHierarchyRow> & { spanId: string; spanName:

const SERVICES = [LONG_SERVICE, "checkout-api", "unknown_service:node", "a"]

// What each span called, cycling down the chain: the waterfall marks each one.
const CALLS: ReadonlyArray<Record<string, string>> = [
{},
{ "db.system.name": "postgresql" },
{ "gen_ai.provider.name": "anthropic" },
{ "messaging.system": "kafka" },
{ "messaging.system": "some_inhouse_queue" },
]

// What runs each service, so a service entering the chain shows its runtime.
const RUNTIMES = new Map<string, Record<string, string>>([
["api", { "telemetry.sdk.name": "effect", "process.runtime.name": "bun" }],
[LONG_SERVICE, { "telemetry.sdk.name": "@maple-dev/effect-sdk", "process.runtime.name": "nodejs" }],
["checkout-api", { "process.runtime.name": "bun" }],
["unknown_service:node", { "telemetry.sdk.language": "python" }],
])

/** A single parent chain, `depth` spans deep, so indentation is the only thing that grows. */
function chain(depth: number, worst: boolean): SpanHierarchyRow[] {
return Array.from({ length: depth }, (_, i) =>
row({
return Array.from({ length: depth }, (_, i) => {
const serviceName = worst ? SERVICES[i % SERVICES.length]! : "api"
return row({
spanId: `d${i}`,
parentSpanId: i === 0 ? "" : `d${i - 1}`,
spanName: worst
? `${i === 0 ? "POST" : "call"} /api/v2/organizations/{orgId}/projects/{projectId}/checkout/step-${i}`
: `step ${i}`,
serviceName: worst ? SERVICES[i % SERVICES.length]! : "api",
serviceName,
spanKind: i === 0 ? "Server" : "Internal",
spanAttributes: JSON.stringify(CALLS[i % CALLS.length]),
resourceAttributes: JSON.stringify(RUNTIMES.get(serviceName) ?? {}),
startTime: formatWarehouseDateTimeMs(T0_MS + i * 5),
durationMs: Math.max(0.001, 2000 - i * 33),
statusCode: worst && i === depth - 1 ? "Error" : "Ok",
statusMessage: worst && i === depth - 1 ? "x".repeat(300) : "",
}),
)
})
})
}

export function worstCaseTrace(mode: WorstCaseMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Message, OutMessage } from "./message"
import type { Model } from "./model"
import { init, setSelectedSpanId, update } from "./update"
import { view as timelineView } from "./view"
import { attributeKeys } from "./view-chrome"

const traceId = Schema.decodeSync(TraceId)("6843a8853182b32339b2dd5120d423f2")
const spanId = Schema.decodeSync(SpanId)
Expand Down Expand Up @@ -156,6 +157,14 @@ describe("trace timeline", () => {
expect(stepped.focusedIndex).toBe(0)
})

test("Color-by offers only keys a span actually carries, not the query's empty projections", () => {
const projected = { "server.address": "db.internal", "rpc.system": "", "gen_ai.system": "" }
const keys = attributeKeys([
{ ...root, spanAttributes: projected, children: [{ ...child, spanAttributes: projected }] },
])
expect(keys).toEqual([{ kind: "spanAttribute", key: "server.address", count: 2 }])
})

test("picking a color scheme is reported to the page; the same one is ignored", () => {
const model = entered()
expect(update(model, Message.SelectedColorBy({ colorBy: "preset:spanKind" })).outMessage).toEqual(
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/page/traces/detail/timeline/view-chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export const searchBar = (h: H, model: Model, matchCount: number, totalCount: nu
h.button(
[
h.AriaLabel("Clear search"),
h.Class("-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-foreground active:text-foreground"),
h.Class(
"-m-1.5 shrink-0 p-1.5 text-muted-foreground hover:text-foreground active:text-foreground",
),
h.Type("button"),
h.OnClick(Message.ClickedClearSearch()),
],
Expand All @@ -70,8 +72,11 @@ const countKeys = (
read: (span: SpanNodeData) => Readonly<Record<string, string>>,
) => {
const counts = new Map<string, number>()
// The trace query projects fixed keys as "" when a span lacks them; only real values count.
const visit = (span: SpanNodeData): void => {
Object.keys(read(span)).forEach((key) => counts.set(key, (counts.get(key) ?? 0) + 1))
Object.entries(read(span)).forEach(([key, value]) => {
if (value !== "") counts.set(key, (counts.get(key) ?? 0) + 1)
})
span.children.forEach(visit)
}
spans.forEach(visit)
Expand All @@ -81,7 +86,7 @@ const countKeys = (
}

/** `useTraceAttributeKeys`: the trace's commonest span and resource attribute keys, 20 of each. */
const attributeKeys = (rootSpans: ReadonlyArray<SpanNodeData>): ReadonlyArray<AttributeKey> => [
export const attributeKeys = (rootSpans: ReadonlyArray<SpanNodeData>): ReadonlyArray<AttributeKey> => [
...countKeys(rootSpans, (span) => span.spanAttributes).map(([key, count]) => ({
kind: "spanAttribute" as const,
key,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/page/traces/detail/waterfall/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { IconName } from "../../../../ui/icon"
// label, icon, edge, location and outcome; the accents come from the shared @maple/ui module.

export interface RowPlatform {
readonly id: string
readonly label: string
readonly icon: IconName
readonly accentClassName: string
Expand Down
59 changes: 40 additions & 19 deletions apps/web/src/page/traces/detail/waterfall/row.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheResultStyles, getCacheInfo } from "@maple/ui/lib/cache"
import { CLOUDFLARE_ACCENT_CLASS } from "@maple/ui/lib/cloud-platforms/accents"
import { getServiceColor } from "@maple/ui/lib/colors"
import { formatDuration } from "@maple/ui/lib/format"
import { getHttpInfo, HTTP_METHOD_COLORS, httpStatusTone } from "@maple/ui/lib/http"
Expand All @@ -18,6 +19,7 @@ import { serviceDot } from "../../../../ui/values"
import type { SpanNodeData } from "../trace-data"
import { Message } from "./model"
import { rowPlatform } from "./platform"
import { type Mark, serviceRuntime, spanTech } from "./tech"
import { countDescendants, type TreeRow, treeLevel } from "./tree"

// `SpanRow` (packages/ui/src/components/traces/span-row.tsx) and `PixelDurationBar`.
Expand Down Expand Up @@ -155,10 +157,7 @@ const pixelBar = (
((i + 1) / TOTAL_BLOCKS) * 100 > leftPercent && (i / TOTAL_BLOCKS) * 100 < spanEnd
return h.div(
[
h.Attribute(
"class",
`w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? "" : "bg-muted"}`,
),
h.Attribute("class", `w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? "" : "bg-muted"}`),
...(isActive ? [h.Style({ "background-color": color })] : []),
],
[],
Expand All @@ -167,6 +166,21 @@ const pixelBar = (
)
}

const markIcon = (h: HtmlBuilder<Message>, mark: Mark): Html =>
h.span(
[h.Class("flex shrink-0"), h.Role("img"), h.AriaLabel(mark.label), h.Title(mark.label)],
[icon(h, mark.icon, { size: 12, className: mark.className })],
)

/** An empty tech slot, so names align whether or not a row has a mark. */
const emptySlot = (h: HtmlBuilder<Message>): Html => h.span([h.Class("w-3 shrink-0")], [])

/** What the span called (Postgres, Kafka, an LLM), left of its name. */
const techIcon = (h: HtmlBuilder<Message>, span: SpanNodeData): Html => {
const tech = spanTech({ ...span.spanAttributes })
return tech === null ? emptySlot(h) : markIcon(h, tech)
}

const missingRow = (h: HtmlBuilder<Message>, row: TreeRow, ctx: RowContext): Html => {
const span = row.node
return h.div(
Expand All @@ -184,6 +198,7 @@ const missingRow = (h: HtmlBuilder<Message>, row: TreeRow, ctx: RowContext): Htm
[
...treeGuides(h, row, ctx.isExpanded),
toggle(h, span, ctx.isExpanded),
emptySlot(h),
badge(
h,
{
Expand Down Expand Up @@ -221,26 +236,27 @@ const missingRow = (h: HtmlBuilder<Message>, row: TreeRow, ctx: RowContext): Htm

type HttpInfo = NonNullable<ReturnType<typeof getHttpInfo>>

/** The service where the call path enters it; a child in its parent's service keeps only the platform. */
/** The Cloudflare Worker mark, else the service's runtime or Effect. */
const serviceMark = (span: SpanNodeData): Mark | null => {
// Workers report `cloud.platform` on the resource; Worker spans may carry it themselves.
const isWorker =
rowPlatform({ ...span.spanAttributes })?.id === "cloudflare" ||
span.resourceAttributes["cloud.platform"] === "cloudflare.workers"
if (isWorker)
return { icon: "CloudflareIcon", label: "Cloudflare Worker", className: CLOUDFLARE_ACCENT_CLASS }
return serviceRuntime({ ...span.resourceAttributes })
}

/** The service where the call path enters it, with what runs it; a child in its parent's service shows none. */
const serviceLabel = (h: HtmlBuilder<Message>, row: TreeRow): ReadonlyArray<Html> => {
const span = row.node
const platform = rowPlatform({ ...span.spanAttributes })
const platformIcon =
platform === null
? []
: [
icon(h, platform.icon, {
size: 12,
className: cn("shrink-0", platform.accentClassName),
attributes: [h.AriaLabel(platform.label)],
}),
]
if (row.parent !== null && row.parent.serviceName === span.serviceName) return platformIcon
if (row.parent !== null && row.parent.serviceName === span.serviceName) return []
const mark = serviceMark(span)
return [
h.span(
[h.Class("flex min-w-0 max-w-[30%] shrink items-center gap-1.5 font-mono text-3xs")],
[
...platformIcon,
...(mark === null ? [] : [markIcon(h, mark)]),
serviceDot(h, { serviceName: span.serviceName, className: "size-1.5" }),
h.span(
[
Expand All @@ -250,7 +266,11 @@ const serviceLabel = (h: HtmlBuilder<Message>, row: TreeRow): ReadonlyArray<Html
],
[span.serviceName],
),
h.span([h.Class("shrink-0 text-foreground-subtle")], [getSpanKindLabel(span.spanKind)]),
// The kind yields first on narrow rows so the service name stays readable.
h.span(
[h.Class("hidden shrink-0 text-foreground-subtle @min-[720px]/row:inline")],
[getSpanKindLabel(span.spanKind)],
),
],
),
]
Expand Down Expand Up @@ -461,6 +481,7 @@ export const spanRow = (h: HtmlBuilder<Message>, row: TreeRow, ctx: RowContext):
[
...treeGuides(h, row, ctx.isExpanded),
toggle(h, span, ctx.isExpanded),
techIcon(h, span),
nameLabel(h, span, httpInfo),
...serviceLabel(h, row),
...edgeLabel(h, span),
Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/page/traces/detail/waterfall/tech.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest"
import { serviceRuntime, spanTech } from "./tech"

describe("spanTech", () => {
test("names the system a span called, by its brand where there is one", () => {
expect(spanTech({ "db.system.name": "postgresql" })).toMatchObject({
icon: "PostgresIcon",
label: "PostgreSQL",
})
expect(spanTech({ "db.system": "redis" })).toMatchObject({ icon: "RedisIcon", label: "Redis" })
expect(spanTech({ "messaging.system": "kafka" })).toMatchObject({ icon: "KafkaIcon", label: "Kafka" })
expect(spanTech({ "rpc.system": "grpc" })).toMatchObject({ icon: "ConnectionIcon", label: "gRPC" })
})

test("an LLM call beats the transport it went over, and the model vendor picks the mark", () => {
expect(
spanTech({ "gen_ai.provider.name": "anthropic", "http.request.method": "POST" }),
).toMatchObject({ icon: "AnthropicIcon" })
expect(spanTech({ "gen_ai.system": "gcp.vertex_ai" })).toMatchObject({
icon: "GoogleIcon",
label: "LLM call · Vertex AI",
})
expect(spanTech({ "gen_ai.provider.name": "openai" })).toMatchObject({ label: "LLM call · OpenAI" })
expect(spanTech({ "gen_ai.provider.name": "some-new-lab" })).toMatchObject({
icon: "ChatBubbleSparkleIcon",
label: "LLM call · Some New Lab",
})
})

test("a database beats a queue or RPC system on the same span", () => {
expect(spanTech({ "db.system.name": "postgresql", "messaging.system": "kafka" })).toMatchObject({
icon: "PostgresIcon",
})
expect(spanTech({ "db.system": "dynamodb", "rpc.system": "aws-api" })).toMatchObject({
icon: "DatabaseIcon",
label: "DynamoDB",
})
})

test("an unknown system keeps a generic glyph and a readable name", () => {
expect(spanTech({ "messaging.system": "cloud_tasks" })).toMatchObject({
icon: "PaperPlaneIcon",
label: "Cloud Tasks",
})
expect(spanTech({ "db.system.name": "Other_SQL" })).toMatchObject({ label: "Other Sql" })
})

test("system values match whatever their case or padding", () => {
expect(spanTech({ "messaging.system": " Kafka " })).toMatchObject({ icon: "KafkaIcon" })
expect(spanTech({ "db.system.name": "PostgreSQL" })).toMatchObject({ icon: "PostgresIcon" })
})

test("a span that names no system, or only blank ones, gets no mark", () => {
expect(spanTech({})).toBeNull()
expect(
spanTech({ "http.request.method": "GET", "db.system.name": "", "messaging.system": " " }),
).toBeNull()
})
})

describe("serviceRuntime", () => {
test("the Effect SDK wins and names the runtime it ran on", () => {
expect(
serviceRuntime({ "telemetry.sdk.name": "effect", "process.runtime.name": "bun" }),
).toMatchObject({ icon: "EffectIcon", label: "Effect on Bun" })
expect(serviceRuntime({ "telemetry.sdk.name": "@maple-dev/effect-sdk" })).toMatchObject({
icon: "EffectIcon",
label: "Effect",
})
})

test("the runtime name beats the SDK language, which is the fallback", () => {
expect(
serviceRuntime({ "process.runtime.name": "bun", "telemetry.sdk.language": "nodejs" }),
).toMatchObject({ icon: "BunIcon", label: "Bun" })
expect(serviceRuntime({ "telemetry.sdk.language": "python" })).toMatchObject({
icon: "PythonIcon",
label: "Python",
})
})

test("a runtime name with no glyph falls back to the SDK language", () => {
expect(
serviceRuntime({ "process.runtime.name": "pypy", "telemetry.sdk.language": "python" }),
).toMatchObject({ icon: "PythonIcon" })
})

test("a runtime with no glyph, or no runtime at all, gets no mark", () => {
expect(serviceRuntime({ "telemetry.sdk.language": "go" })).toBeNull()
expect(serviceRuntime({ "telemetry.sdk.language": "webjs" })).toBeNull()
expect(serviceRuntime({})).toBeNull()
})
})
Loading
Loading