diff --git a/apps/ai/src/mcp/__evals__/fixtures.ts b/apps/ai/src/mcp/__evals__/fixtures.ts index 2066ee771..7c4822ac7 100644 --- a/apps/ai/src/mcp/__evals__/fixtures.ts +++ b/apps/ai/src/mcp/__evals__/fixtures.ts @@ -112,6 +112,7 @@ export const makeSpanDetailRows = (): ReadonlyArray> => // the compiled query's schema now, so a partial one is a decode failure. parentSpanId: "", spanName: "POST /api/checkout", + rawSpanName: "POST", serviceName: FIXTURES.service, spanKind: "Server", durationMs: 120, @@ -168,6 +169,7 @@ export const makeAiSpanDetailRows = ( spanId, parentSpanId: "", spanName: "chat gpt-5", + rawSpanName: "chat gpt-5", serviceName: FIXTURES.service, spanKind: "Client", durationMs: 1_200, diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 7a71bf886..1cfe4face 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -7,6 +7,7 @@ import { type RawSqlValidationError, SpanHierarchyResponse, SpanDetailResponse, + TraceSpanEventsResponse, ErrorsByTypeResponse, ErrorsSparkResponse, ErrorsSummaryResponse, @@ -114,6 +115,7 @@ import { computeBucketSecondsForRange, formatWarehouseDateTime, QueryEngineExecuteBatchResponse, + parseSpanEvents, parseWarehouseDateTime, } from "@maple/query-engine" @@ -517,14 +519,48 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const row = yield* runQueryFirst(Queries.spanDetail, tenant, payload) + if (!row) return new SpanDetailResponse({ data: null }) + // `trace_detail_spans` has no Events columns; read them off `traces` + // by the sorting key the detail row just resolved. + const startMs = DateTime.toEpochMillis(row.startTime) + const events = yield* runQueryFirst(Queries.spanEvents, tenant, { + traceId: row.traceId, + spanId: row.spanId, + serviceName: row.serviceName, + spanName: row.rawSpanName, + startTime: formatWarehouseDateTime(startMs), + endTime: formatWarehouseDateTime(startMs + 1000), + }).pipe( + Effect.map((raw) => (raw ? parseSpanEvents(raw) : [])), + // Events are supplementary; a failed lookup must not hide the attributes. + Effect.tapError((error) => Effect.logWarning("span events lookup failed", error)), + Effect.orElseSucceed(() => []), + ) return new SpanDetailResponse({ - data: row - ? { - ...row, - traceId: decodeTraceId(row.traceId), - spanId: decodeSpanId(row.spanId), - } - : null, + data: { + traceId: decodeTraceId(row.traceId), + spanId: decodeSpanId(row.spanId), + spanAttributes: row.spanAttributes, + resourceAttributes: row.resourceAttributes, + events, + }, + }) + }), + ) + .handle("traceSpanEvents", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.traceSpanEvents, tenant, payload) + return new TraceSpanEventsResponse({ + data: rows.flatMap((row) => { + const events = parseSpanEvents(row).map(({ name, offsetMs }) => ({ + name, + offsetMs, + })) + return events.length === 0 + ? [] + : [{ spanId: decodeSpanId(row.spanId), events }] + }), }) }), ) diff --git a/apps/api/src/routes/queries.ts b/apps/api/src/routes/queries.ts index 5eb910033..607b4821f 100644 --- a/apps/api/src/routes/queries.ts +++ b/apps/api/src/routes/queries.ts @@ -24,6 +24,7 @@ import type { RailwayInfraServiceTimeseriesRequest, ServiceCloudflareStatsRequest, SpanDetailRequest, + TraceSpanEventsRequest, WorkloadInfraTimeseriesRequest, } from "@maple/domain/http" import { @@ -673,6 +674,50 @@ export const Queries = { }, }), + /** + * Events of a span `spanDetail` already resolved. Keyed by the span's exact + * start second, so the row it reads is written and immutable. + */ + spanEvents: defineQuery({ + id: "spanEvents", + profile: "discovery", + cache: 600, + compile: ( + payload: { + readonly traceId: string + readonly spanId: string + readonly serviceName: string + readonly spanName: string + readonly startTime: string + readonly endTime: string + }, + orgId: OrgId, + ) => + CH.compile( + CH.spanEventsQuery({ + traceId: payload.traceId, + spanId: payload.spanId, + serviceName: payload.serviceName, + spanName: payload.spanName, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), + }), + + /** Event times and names for every span of a trace, for the timeline markers. */ + traceSpanEvents: defineQuery({ + id: "traceSpanEvents", + profile: "list", + cache: (payload: TraceSpanEventsRequest, nowMs: number) => + traceCacheTtlSeconds(payload.endTime, nowMs), + compile: (payload: TraceSpanEventsRequest, orgId: OrgId) => + CH.compile(CH.spanEventsByTraceQuery({ traceId: payload.traceId }), { + orgId, + startTime: payload.startTime, + endTime: payload.endTime, + }), + }), + podInfraTimeseries: defineQuery({ id: "podInfraTimeseries", profile: "aggregation", diff --git a/apps/web/src/api/warehouse/traces.ts b/apps/web/src/api/warehouse/traces.ts index 1afd469d7..e6fab1714 100644 --- a/apps/web/src/api/warehouse/traces.ts +++ b/apps/web/src/api/warehouse/traces.ts @@ -15,7 +15,10 @@ import { SpanDetailRequest, SpanHierarchyRequest, SpanName, + TraceSpanEventsRequest, } from "@maple/domain/http" +import type { SpanEvent } from "@maple/query-engine" +import type { SpanEventsBySpanId } from "@maple/ui/lib/span-event-markers" import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { computeTraceTimeWindow } from "@/lib/trace-time-window" import { @@ -578,6 +581,7 @@ export type GetSpanDetailInput = Schema.Schema.Type resourceAttributes: Record + events: ReadonlyArray } export function getSpanDetail({ data }: { data: GetSpanDetailInput }) { @@ -612,6 +616,7 @@ const getSpanDetailEffect = Effect.fn("QueryEngine.getSpanDetail")(function* ({ return { spanAttributes: parseAttributes(result.data?.spanAttributes), resourceAttributes: parseAttributes(result.data?.resourceAttributes), + events: result.data?.events ?? [], } satisfies SpanDetailResult }) @@ -757,6 +762,52 @@ const getTracesFacetsEffect = Effect.fn("QueryEngine.getTracesFacets")(function* } }) +// Trace span events — event times and names for every span, drawn as markers on +// the timeline bars. A separate request so the hierarchy never waits on `traces`. + +/** Slack around the trace's own span window for sub-second rounding and late events. */ +const TRACE_EVENTS_SLACK_MS = 1_000 + +const GetTraceSpanEventsInputSchema = Schema.Struct({ + traceId: TraceId, + /** Earliest span start (`SpanHierarchyResponse.traceStartTime`). */ + traceStartTime: Schema.String, + totalDurationMs: Schema.Number, +}) + +export type GetTraceSpanEventsInput = Schema.Schema.Type + +export function getTraceSpanEvents({ data }: { data: GetTraceSpanEventsInput }) { + return getTraceSpanEventsEffect({ data }) +} + +const getTraceSpanEventsEffect = Effect.fn("QueryEngine.getTraceSpanEvents")(function* ({ + data, +}: { + data: GetTraceSpanEventsInput +}) { + const input = yield* decodeInput(GetTraceSpanEventsInputSchema, data, "getTraceSpanEvents") + yield* Effect.annotateCurrentSpan("traceId", input.traceId) + + const startMs = Date.parse(input.traceStartTime) + if (Number.isNaN(startMs)) return new Map() satisfies SpanEventsBySpanId + + const result = yield* runWarehouseQuery("traceSpanEvents", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.queryEngine.traceSpanEvents({ + payload: new TraceSpanEventsRequest({ + traceId: input.traceId, + startTime: formatWarehouseDateTime(startMs - TRACE_EVENTS_SLACK_MS), + endTime: formatWarehouseDateTime(startMs + input.totalDurationMs + TRACE_EVENTS_SLACK_MS), + }), + }) + }), + ) + + return new Map(result.data.map((row) => [row.spanId, row.events])) satisfies SpanEventsBySpanId +}) + // Single facet list for dashboard variables: compiles only the requested UNION // branch server-side and skips the duration-stats companion query, instead of // the full multi-facet scan `getTracesFacets` runs for the traces sidebar. diff --git a/apps/web/src/components/traces/span-detail-panel.tsx b/apps/web/src/components/traces/span-detail-panel.tsx index bac657012..3ca798882 100644 --- a/apps/web/src/components/traces/span-detail-panel.tsx +++ b/apps/web/src/components/traces/span-detail-panel.tsx @@ -35,6 +35,7 @@ import { ResourceAttributesSection, } from "@/components/attributes" import { TraceAttributeFilterProvider } from "./trace-attribute-filter-provider" +import { SpanEventsSection } from "./span-events-section" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatTimestampInTimezone } from "@/lib/timezone-format" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" @@ -473,6 +474,7 @@ export function SpanDetailPanel({ )) .onSuccess((detail) => ( <> + > }) { + const entries = Object.entries(attributes) + if (entries.length === 0) return null + return ( + + {entries.map(([key, value]) => ( + + {value} + + ))} + + ) +} + +function SpanEventRow({ event }: { event: SpanEvent }) { + const described = describeSpanEvent(event) + const offset = ( + + {formatEventOffset(event.offsetMs)} + + ) + + if (described.kind === "event") { + return ( +
  • +
    + {offset} + {event.name || "(unnamed)"} +
    + +
  • + ) + } + + return ( +
  • +
    + {offset} + + + {described.type ?? "exception"} + +
    + {described.message && ( +

    + {described.message} +

    + )} + {described.stacktrace && ( + +
    + + + Stack trace + + +
    + +
    +							{described.stacktrace}
    +						
    +
    +
    + )} + +
  • + ) +} + +/** The span's OTel events in time order, with `exception` events expanded. */ +export function SpanEventsSection({ events }: { events: ReadonlyArray }) { + if (events.length === 0) return null + return ( +
    +

    Events ({events.length})

    +
      + {events.map((event, index) => ( + + ))} +
    +
    + ) +} diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 25032f298..62593a6cb 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -87,7 +87,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/timeline", title: "Trace timeline", description: - "The trace view tabs over a synthetic trace with a wide dynamic range — a 2s root down to sub-millisecond spans that only resolve at deep zoom.", + "The trace view tabs over a synthetic trace with a wide dynamic range — a 2s root down to sub-millisecond spans that only resolve at deep zoom, with span event markers and the events panel.", kind: "lab", session: "none", }, diff --git a/apps/web/src/lab/timeline-fixture.ts b/apps/web/src/lab/timeline-fixture.ts index 717ee505c..39d136acd 100644 --- a/apps/web/src/lab/timeline-fixture.ts +++ b/apps/web/src/lab/timeline-fixture.ts @@ -1,5 +1,6 @@ import { buildTraceDetail, type SpanHierarchyRow } from "@maple/ui/lib/span-tree" -import { formatWarehouseDateTimeMs } from "@maple/query-engine" +import { formatWarehouseDateTimeMs, type SpanEvent } from "@maple/query-engine" +import type { SpanEventsBySpanId } from "@maple/ui/lib/span-event-markers" const T0_MS = new Date("2026-07-21T10:00:00.000Z").getTime() @@ -65,3 +66,48 @@ const ROWS: SpanHierarchyRow[] = [ /** Synthetic trace for the timeline lab: 2s root, mid-size children, sub-ms fan. */ export const TIMELINE_LAB_TRACE = buildTraceDetail(ROWS) + +const event = ( + spanStartMs: number, + offsetMs: number, + name: string, + attributes: Record = {}, +): SpanEvent => ({ + name, + timestamp: new Date(T0_MS + spanStartMs + offsetMs).toISOString(), + offsetMs, + attributes, +}) + +/** OTel events on a few spans: plain events, an exception, one stamped before its span (clock skew). */ +export const TIMELINE_LAB_EVENTS: ReadonlyMap> = new Map([ + [ + "root", + [ + event(0, 4.2, "cache.miss", { "cache.key": "cart:8f2c" }), + event(0, 1210.0004, "retry", { "retry.attempt": "2", "retry.reason": "upstream 503" }), + ], + ], + ["svc0", [event(20, 1.75, "db.connection.acquired", { "db.pool.wait_ms": "1.7" })]], + [ + "svc3", + [ + event(920, -0.6, "request.received"), + event(920, 180.25, "exception", { + "exception.type": "SearchTimeoutError", + "exception.message": "search backend did not answer within 150ms", + "exception.stacktrace": + "SearchTimeoutError: search backend did not answer within 150ms\n at query (search/client.ts:88:11)\n at handler (search/route.ts:41:5)", + "exception.escaped": "true", + }), + ], + ], +]) + +/** The timeline only needs names and offsets. */ +export const TIMELINE_LAB_MARKERS: SpanEventsBySpanId = new Map( + [...TIMELINE_LAB_EVENTS].map(([spanId, events]) => [ + spanId, + events.map(({ name, offsetMs }) => ({ name, offsetMs })), + ]), +) diff --git a/apps/web/src/lab/timeline-lab.tsx b/apps/web/src/lab/timeline-lab.tsx index dc693fd77..389444ca7 100644 --- a/apps/web/src/lab/timeline-lab.tsx +++ b/apps/web/src/lab/timeline-lab.tsx @@ -3,21 +3,35 @@ import { useState } from "react" import { TraceViewTabs } from "@maple/ui/components/traces/trace-view-tabs" import type { SpanNode } from "@maple/ui/lib/types" -import { TIMELINE_LAB_TRACE as DETAIL } from "@/lab/timeline-fixture" +import { SpanEventsSection } from "@/components/traces/span-events-section" +import { + TIMELINE_LAB_TRACE as DETAIL, + TIMELINE_LAB_EVENTS, + TIMELINE_LAB_MARKERS, +} from "@/lab/timeline-fixture" export function TimelineLab() { const [selected, setSelected] = useState(undefined) + const events = selected ? TIMELINE_LAB_EVENTS.get(selected.spanId) : undefined return ( -
    - +
    +
    + +
    + {events && ( + + )}
    ) } diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index 92ee93355..c65a96c49 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -100,6 +100,7 @@ import { getSpanAttributeValues, getSpanDetail, getSpanHierarchy, + getTraceSpanEvents, getTracesFacetValues, getTracesFacets, listTraces, @@ -501,6 +502,10 @@ export const getReplaysForTraceResultAtom = makeQueryAtomFamily(getReplaysForTra staleTime: 60_000, }) +export const getTraceSpanEventsResultAtom = makeQueryAtomFamily(getTraceSpanEvents, { + staleTime: 60_000, +}) + export const getSpanDetailResultAtom = makeQueryAtomFamily(getSpanDetail, { staleTime: 60_000, }) diff --git a/apps/web/src/lib/traces/span-events.test.ts b/apps/web/src/lib/traces/span-events.test.ts new file mode 100644 index 000000000..fac6a9b1c --- /dev/null +++ b/apps/web/src/lib/traces/span-events.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { describeSpanEvent, formatEventOffset } from "./span-events" + +const event = (name: string, attributes: Record) => ({ + name, + timestamp: "2026-03-20T08:00:00.000Z", + offsetMs: 0, + attributes, +}) + +describe("describeSpanEvent", () => { + it("pulls the semconv fields out of an exception event", () => { + const described = describeSpanEvent( + event("exception", { + "exception.type": "TypeError", + "exception.message": "x is undefined", + "exception.stacktrace": "TypeError: x is undefined\n at f (a.js:1:1)", + "exception.escaped": "true", + }), + ) + expect(described).toEqual({ + kind: "exception", + type: "TypeError", + message: "x is undefined", + stacktrace: "TypeError: x is undefined\n at f (a.js:1:1)", + attributes: { "exception.escaped": "true" }, + }) + }) + + it("treats blank exception fields as absent", () => { + const described = describeSpanEvent(event("exception", { "exception.message": "" })) + expect(described).toMatchObject({ kind: "exception", message: undefined, stacktrace: undefined }) + }) + + it("passes other events through with all attributes", () => { + expect(describeSpanEvent(event("cache.miss", { "exception.type": "kept" }))).toEqual({ + kind: "event", + attributes: { "exception.type": "kept" }, + }) + }) +}) + +describe("formatEventOffset", () => { + it("signs the offset and keeps microsecond precision", () => { + expect(formatEventOffset(12.4)).toBe("+12.400ms") + expect(formatEventOffset(0.25)).toBe("+250μs") + expect(formatEventOffset(-1.5)).toBe("-1.500ms") + expect(formatEventOffset(2500)).toBe("+2.500s") + expect(formatEventOffset(1000)).toBe("+1.000s") + expect(formatEventOffset(1000.123)).toBe("+1.000123s") + expect(formatEventOffset(-61_000.5)).toBe("-61.0005s") + }) +}) diff --git a/apps/web/src/lib/traces/span-events.ts b/apps/web/src/lib/traces/span-events.ts new file mode 100644 index 000000000..0cf435441 --- /dev/null +++ b/apps/web/src/lib/traces/span-events.ts @@ -0,0 +1,44 @@ +import type { SpanEvent } from "@maple/query-engine" +import { formatDurationAtStep } from "@maple/ui/lib/format" + +/** OTel semconv attribute keys on an `exception` span event. */ +const EXCEPTION_KEYS = ["exception.type", "exception.message", "exception.stacktrace"] as const + +export type DescribedSpanEvent = + | { + readonly kind: "exception" + readonly type: string | undefined + readonly message: string | undefined + readonly stacktrace: string | undefined + /** Attributes beyond the three exception keys, e.g. `exception.escaped`. */ + readonly attributes: Readonly> + } + | { readonly kind: "event"; readonly attributes: Readonly> } + +const nonEmpty = (value: string | undefined) => (value ? value : undefined) + +/** Splits an `exception` event's semconv fields from the rest of its attributes. */ +export function describeSpanEvent(event: SpanEvent): DescribedSpanEvent { + if (event.name !== "exception") return { kind: "event", attributes: event.attributes } + const attributes = Object.fromEntries( + Object.entries(event.attributes).filter( + ([key]) => !(EXCEPTION_KEYS as readonly string[]).includes(key), + ), + ) + return { + kind: "exception", + type: nonEmpty(event.attributes["exception.type"]), + message: nonEmpty(event.attributes["exception.message"]), + stacktrace: nonEmpty(event.attributes["exception.stacktrace"]), + attributes, + } +} + +/** `+12.400ms`: the event's offset into its span, to the microsecond. */ +export function formatEventOffset(offsetMs: number): string { + const sign = offsetMs < 0 ? "-" : "+" + const magnitude = Math.abs(offsetMs) + // Past a second the shared formatter rounds to the millisecond; keep the microseconds. + if (magnitude >= 1000) return `${sign}${(magnitude / 1000).toFixed(6).replace(/0{1,3}$/, "")}s` + return `${sign}${formatDurationAtStep(magnitude, 0.001)}` +} diff --git a/apps/web/src/routes/traces/$traceId.tsx b/apps/web/src/routes/traces/$traceId.tsx index a5494bb4f..fe2a3e673 100644 --- a/apps/web/src/routes/traces/$traceId.tsx +++ b/apps/web/src/routes/traces/$traceId.tsx @@ -1,7 +1,7 @@ import { warmAtoms } from "@effect-router/core" import * as React from "react" import { useNavigate, useRouterState, createFileRoute } from "@tanstack/react-router" -import { useAtomValue } from "@/lib/effect-atom" +import { Result, useAtomValue } from "@/lib/effect-atom" import { Option, Schema } from "effect" import { TraceId } from "@maple/domain" @@ -23,7 +23,10 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@maple/ui/ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@maple/ui/components/ui/sheet" import { useIsMobile } from "@maple/ui/hooks/use-media-query" import { type Span, type SpanNode, type SpanHierarchyResponse } from "@/api/warehouse/traces" -import { getSpanHierarchyResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { + getSpanHierarchyResultAtom, + getTraceSpanEventsResultAtom, +} from "@/lib/services/atoms/warehouse-query-atoms" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" import { findSpanById } from "@maple/ui/components/traces/flow-utils" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" @@ -265,6 +268,14 @@ function TraceDetailBody({ const isMobile = useIsMobile() + // Event markers arrive after the bars; until then (or on failure) the timeline draws none. + const spanEventsResult = useAtomValue( + getTraceSpanEventsResultAtom({ + data: { traceId: data.traceId, traceStartTime, totalDurationMs: data.totalDurationMs }, + }), + ) + const spanEvents = Result.isSuccess(spanEventsResult) ? spanEventsResult.value : undefined + // Shared by both layouts below so the tabs keep their state across a breakpoint change. const traceViewTabs = ( ) diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 4ace0d12d..8c769345f 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -111,6 +111,41 @@ export class SpanDetailRequest extends Schema.Class("SpanDeta endTime: Schema.optional(TinybirdDateTime), }) {} +/** One OTel span event; `exception` events carry `exception.*` attributes. */ +export const SpanEventSchema = Schema.Struct({ + name: Schema.String, + /** ISO-8601 UTC, millisecond precision. */ + timestamp: Schema.String, + /** Event time minus span start, in ms (sub-millisecond precision). */ + offsetMs: Schema.Number, + attributes: Schema.Record(Schema.String, Schema.String), +}) + +export class TraceSpanEventsRequest extends Schema.Class("TraceSpanEventsRequest")({ + traceId: TraceId, + /** The trace's own span window, so `traces` is read by its time range. */ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, +}) {} + +/** Event markers for one span of the trace timeline. */ +export const TimelineSpanEventSchema = Schema.Struct({ + name: Schema.String, + /** Event time minus span start, in ms. */ + offsetMs: Schema.Number, +}) + +export class TraceSpanEventsResponse extends Schema.Class("TraceSpanEventsResponse")( + { + data: Schema.Array( + Schema.Struct({ + spanId: SpanId, + events: Schema.Array(TimelineSpanEventSchema), + }), + ), + }, +) {} + export class SpanDetailResponse extends Schema.Class("SpanDetailResponse")({ data: Schema.NullOr( Schema.Struct({ @@ -118,6 +153,7 @@ export class SpanDetailResponse extends Schema.Class("SpanDe spanId: SpanId, spanAttributes: Schema.String, resourceAttributes: Schema.String, + events: Schema.optionalKey(Schema.Array(SpanEventSchema)), }), ), }) {} @@ -2602,6 +2638,13 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("traceSpanEvents", "/trace-span-events", { + payload: TraceSpanEventsRequest, + success: TraceSpanEventsResponse, + error: queryEngineEndpointErrors, + }), + ) .add( HttpApiEndpoint.post("errorsByType", "/errors-by-type", { payload: ErrorsByTypeRequest, diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index dbf0f2842..febee4074 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -835,12 +835,13 @@ SELECT GROUP BY fingerprintHash FORMAT JSON --- builder:errors:spanDetailQuery:default [02aceae6] +-- builder:errors:spanDetailQuery:default [a53b55ab] SELECT trace_detail_spans.TraceId AS traceId, trace_detail_spans.SpanId AS spanId, trace_detail_spans.ParentSpanId AS parentSpanId, if(((trace_detail_spans.SpanName LIKE 'http.server %' OR trace_detail_spans.SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (trace_detail_spans.SpanAttributes['http.route'] != '' OR trace_detail_spans.SpanAttributes['url.path'] != '')), concat(if(trace_detail_spans.SpanName LIKE 'http.server %', replaceOne(trace_detail_spans.SpanName, 'http.server ', ''), trace_detail_spans.SpanName), ' ', if(trace_detail_spans.SpanAttributes['http.route'] != '', trace_detail_spans.SpanAttributes['http.route'], trace_detail_spans.SpanAttributes['url.path'])), trace_detail_spans.SpanName) AS spanName, + trace_detail_spans.SpanName AS rawSpanName, trace_detail_spans.ServiceName AS serviceName, trace_detail_spans.SpanKind AS spanKind, trace_detail_spans.Duration / 1000000 AS durationMs, @@ -856,12 +857,13 @@ SELECT LIMIT 1 FORMAT JSON --- builder:errors:spanDetailQuery:narrowByTime [39008ef8] +-- builder:errors:spanDetailQuery:narrowByTime [695c65dd] SELECT trace_detail_spans.TraceId AS traceId, trace_detail_spans.SpanId AS spanId, trace_detail_spans.ParentSpanId AS parentSpanId, if(((trace_detail_spans.SpanName LIKE 'http.server %' OR trace_detail_spans.SpanName IN ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')) AND (trace_detail_spans.SpanAttributes['http.route'] != '' OR trace_detail_spans.SpanAttributes['url.path'] != '')), concat(if(trace_detail_spans.SpanName LIKE 'http.server %', replaceOne(trace_detail_spans.SpanName, 'http.server ', ''), trace_detail_spans.SpanName), ' ', if(trace_detail_spans.SpanAttributes['http.route'] != '', trace_detail_spans.SpanAttributes['http.route'], trace_detail_spans.SpanAttributes['url.path'])), trace_detail_spans.SpanName) AS spanName, + trace_detail_spans.SpanName AS rawSpanName, trace_detail_spans.ServiceName AS serviceName, trace_detail_spans.SpanKind AS spanKind, trace_detail_spans.Duration / 1000000 AS durationMs, @@ -879,6 +881,39 @@ SELECT LIMIT 1 FORMAT JSON +-- builder:errors:spanEventsByTraceQuery:default [b03e077c] +SELECT + traces.SpanId AS spanId, + toString(traces.Timestamp) AS spanStart, + toJSONString(traces.EventsTimestamp) AS eventsTimestamp, + toJSONString(traces.EventsName) AS eventsName + FROM traces + WHERE traces.OrgId = 'org_sql_catalog' + AND traces.Timestamp >= '2026-01-01 10:30:00' + AND traces.Timestamp <= '2026-01-03 14:15:00' + AND traces.TraceId = '0af7651916cd43dd8448eb211c80319c' + AND notEmpty(traces.EventsName) + ORDER BY spanStart ASC + LIMIT 5000 + FORMAT JSON + +-- builder:errors:spanEventsQuery:default [9e3ce44c] +SELECT + toString(traces.Timestamp) AS spanStart, + toJSONString(traces.EventsTimestamp) AS eventsTimestamp, + toJSONString(traces.EventsName) AS eventsName, + toJSONString(traces.EventsAttributes) AS eventsAttributes + FROM traces + WHERE traces.OrgId = 'org_sql_catalog' + AND traces.ServiceName = 'api' + AND traces.SpanName = 'GET' + AND traces.Timestamp >= '2026-01-01 10:30:00' + AND traces.Timestamp <= '2026-01-03 14:15:00' + AND traces.TraceId = '0af7651916cd43dd8448eb211c80319c' + AND traces.SpanId = 'b7ad6b7169203331' + LIMIT 1 + FORMAT JSON + -- builder:errors:tracesDurationStatsQuery:rollup [b66532bf] SELECT minIf(durationMin, traceCount > 0) / 1000000 AS minDurationMs, diff --git a/packages/query-engine/src/benchmark/builders.ts b/packages/query-engine/src/benchmark/builders.ts index 233ecbee4..672f24fb9 100644 --- a/packages/query-engine/src/benchmark/builders.ts +++ b/packages/query-engine/src/benchmark/builders.ts @@ -816,6 +816,29 @@ export const builderFixtures: ReadonlyArray = [ window, ), }, + { + // query-engine.http.ts spanDetail, after the detail row resolves the span's keys + module: "errors", + name: "spanEventsQuery", + label: "default", + compile: () => + CH.compileUnsafe( + CH.spanEventsQuery({ + traceId: TRACE_ID, + spanId: SPAN_ID, + serviceName: "api", + spanName: "GET", + }), + window, + ), + }, + { + // query-engine.http.ts traceSpanEvents, the trace timeline's event markers + module: "errors", + name: "spanEventsByTraceQuery", + label: "default", + compile: () => CH.compileUnsafe(CH.spanEventsByTraceQuery({ traceId: TRACE_ID }), window), + }, { // ErrorsService errorIssuesScan (the scheduled sweep) module: "errors", diff --git a/packages/query-engine/src/ch/ch.test.ts b/packages/query-engine/src/ch/ch.test.ts index a1ae82416..91e794661 100644 --- a/packages/query-engine/src/ch/ch.test.ts +++ b/packages/query-engine/src/ch/ch.test.ts @@ -9,6 +9,8 @@ import { tracesDurationStatsQuery, spanHierarchyQuery, spanDetailQuery, + spanEventsQuery, + spanEventsByTraceQuery, traceTimeProbeQuery, recentTraceTimeProbeQuery, } from "./queries/errors" @@ -944,6 +946,46 @@ describe("converted queries", () => { expect(sql).toContain("Timestamp <= '2026-04-15 15:00:00'") }) + it("spanEventsQuery seeks traces by the sorting-key prefix and returns the Events arrays", () => { + const q = spanEventsQuery({ traceId: "abc", spanId: "s1", serviceName: "api", spanName: "GET" }) + const { sql } = compileUnsafe(q, { + orgId: OrgId.make("org_1"), + startTime: "2026-04-15 13:00:00", + endTime: "2026-04-15 13:00:01", + }) + expect(sql).toContain("FROM traces") + expect(sql).toContain("OrgId = 'org_1'") + expect(sql).toContain("ServiceName = 'api'") + expect(sql).toContain("SpanName = 'GET'") + expect(sql).toContain("Timestamp >= '2026-04-15 13:00:00'") + expect(sql).toContain("Timestamp <= '2026-04-15 13:00:01'") + expect(sql).toContain("TraceId = 'abc'") + expect(sql).toContain("SpanId = 's1'") + expect(sql).toContain("toJSONString(traces.EventsTimestamp) AS eventsTimestamp") + expect(sql).toContain("toJSONString(traces.EventsName) AS eventsName") + expect(sql).toContain("toJSONString(traces.EventsAttributes) AS eventsAttributes") + expect(sql).toContain("LIMIT 1") + }) + + it("spanEventsByTraceQuery reads only spans with events inside the trace window", () => { + const q = spanEventsByTraceQuery({ traceId: "abc", limit: 10 }) + const { sql } = compileUnsafe(q, { + orgId: OrgId.make("org_1"), + startTime: "2026-04-15 13:00:00", + endTime: "2026-04-15 13:00:05", + }) + expect(sql).toContain("FROM traces") + expect(sql).toContain("OrgId = 'org_1'") + expect(sql).toContain("TraceId = 'abc'") + expect(sql).toContain("Timestamp >= '2026-04-15 13:00:00'") + expect(sql).toContain("Timestamp <= '2026-04-15 13:00:05'") + expect(sql).toContain("notEmpty(traces.EventsName)") + expect(sql).toMatch(/ORDER BY spanStart ASC\s+LIMIT 10/) + expect(sql).toContain("toJSONString(traces.EventsName) AS eventsName") + expect(sql).not.toContain("EventsAttributes") + expect(sql).toContain("LIMIT 10") + }) + it("traceTimeProbeQuery is a cheap LIMIT-1 timestamp seek with no ORDER BY", () => { const q = traceTimeProbeQuery({ traceId: "abc123" }) const { sql } = compileUnsafe(q, { orgId: OrgId.make("org_1") }) diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 76cd7dfdf..ae083d8e9 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -344,6 +344,8 @@ export { spanHierarchyQuery, SPAN_HIERARCHY_MAX_SPANS, spanDetailQuery, + spanEventsQuery, + spanEventsByTraceQuery, traceTimeProbeQuery, recentTraceTimeProbeQuery, tracesDurationStatsQuery, @@ -378,6 +380,9 @@ export { type SpanHierarchyOutput, type SpanDetailOpts, type SpanDetailOutput, + type SpanEventsOpts, + type SpanEventsOutput, + type SpanEventsByTraceOutput, type TraceTimeProbeOutput, type TracesDurationStatsOpts, type TracesDurationStatsOutput, diff --git a/packages/query-engine/src/ch/queries/errors.ts b/packages/query-engine/src/ch/queries/errors.ts index 690738d6c..1d0885bd9 100644 --- a/packages/query-engine/src/ch/queries/errors.ts +++ b/packages/query-engine/src/ch/queries/errors.ts @@ -8,7 +8,7 @@ import * as CH from "@maple-dev/effect-orm/expr" // From the root, not `/expr`: these overloads take a `CHQuery`, keeping the // subquery's params, table names and column types checked. import { exists, inSubquery } from "@maple-dev/effect-orm/clickhouse" -import { param } from "@maple-dev/effect-orm/clickhouse" +import { defineCondFn, param } from "@maple-dev/effect-orm/clickhouse" import { from, fromQuery, @@ -468,6 +468,8 @@ export interface SpanDetailOutput { readonly spanId: string readonly parentSpanId: string readonly spanName: string + /** The stored `SpanName`, before the HTTP display rewrite. Keys `spanEventsQuery`. */ + readonly rawSpanName: string readonly serviceName: string readonly spanKind: string readonly durationMs: number @@ -495,6 +497,7 @@ export function spanDetailQuery(opts: SpanDetailOpts) { $.SpanAttributes.get("http.route"), $.SpanAttributes.get("url.path"), ), + rawSpanName: $.SpanName, serviceName: $.ServiceName, spanKind: $.SpanKind, durationMs: $.Duration.div(1000000), @@ -515,6 +518,93 @@ export function spanDetailQuery(opts: SpanDetailOpts) { .format("JSON") } +// Span events — the OTel events (incl. `exception`) recorded on one span + +export interface SpanEventsOpts { + traceId: string + spanId: string + serviceName: string + /** The stored `SpanName` (`SpanDetailOutput.rawSpanName`), not the display name. */ + spanName: string +} + +export interface SpanEventsOutput { + /** The span's own start, rendered like the event timestamps so offsets keep ns precision. */ + readonly spanStart: string + readonly eventsTimestamp: string + readonly eventsName: string + readonly eventsAttributes: string +} + +/** + * Events for one span, read from `traces` because `trace_detail_spans` does not + * carry the Events arrays. `(OrgId, ServiceName, SpanName, toDateTime(Timestamp))` + * is the sorting key, so with the span's exact start second (`startTime` and + * `endTime` one second apart) this seeks a handful of granules. + */ +export function spanEventsQuery(opts: SpanEventsOpts) { + return from(Traces) + .select(($) => ({ + spanStart: CH.toString_($.Timestamp), + eventsTimestamp: CH.toJSONString($.EventsTimestamp), + eventsName: CH.toJSONString($.EventsName), + eventsAttributes: CH.toJSONString($.EventsAttributes), + })) + .where(($) => [ + $.OrgId.eq(orgIdParam), + $.ServiceName.eq(opts.serviceName), + $.SpanName.eq(opts.spanName), + $.Timestamp.gte(param.dateTime("startTime")), + $.Timestamp.lte(param.dateTime("endTime")), + $.TraceId.eq(opts.traceId), + $.SpanId.eq(opts.spanId), + ]) + .limit(1) + .format("JSON") +} + +// Span events by trace — event markers for every span of one trace + +export interface SpanEventsByTraceOutput { + readonly spanId: string + /** The span's own start, rendered like the event timestamps so offsets keep ns precision. */ + readonly spanStart: string + readonly eventsTimestamp: string + readonly eventsName: string +} + +const notEmpty = defineCondFn<[CH.Expr>]>("notEmpty") + +/** + * Event times and names for the spans of one trace that recorded any, for the + * timeline markers. Attributes stay on `spanEventsQuery`, loaded per span. + * `TraceId` is not in `traces`' sorting key; `idx_trace_id` and the trace's own + * `startTime`/`endTime` window keep this to a few granules. + */ +export function spanEventsByTraceQuery(opts: { traceId: string; limit?: number }) { + return ( + from(Traces) + .select(($) => ({ + spanId: $.SpanId, + spanStart: CH.toString_($.Timestamp), + eventsTimestamp: CH.toJSONString($.EventsTimestamp), + eventsName: CH.toJSONString($.EventsName), + })) + .where(($) => [ + $.OrgId.eq(orgIdParam), + $.Timestamp.gte(param.dateTime("startTime")), + $.Timestamp.lte(param.dateTime("endTime")), + $.TraceId.eq(opts.traceId), + notEmpty($.EventsName), + ]) + // Earliest first, like `spanHierarchyQuery`, so the cap keeps the spans the timeline shows. + // `spanStart` is fixed-width DateTime64 text, so it sorts in time order. + .orderBy(["spanStart", "asc"]) + .limit(opts.limit ?? SPAN_HIERARCHY_MAX_SPANS) + .format("JSON") + ) +} + // Trace timestamp probe — resolve any one span timestamp for a trace export interface TraceTimeProbeOutput { diff --git a/packages/query-engine/src/index.ts b/packages/query-engine/src/index.ts index 1fec48233..e53818ed5 100644 --- a/packages/query-engine/src/index.ts +++ b/packages/query-engine/src/index.ts @@ -11,6 +11,7 @@ export * from "./widget-request" export * from "./raw-sql-chart-rows" export * from "./route-rows" export * from "./traces-shared" +export * from "./span-events" export * from "./canonical-json" export * as CH from "./ch" export * as Observability from "./observability" diff --git a/packages/query-engine/src/observability/span-detail.test.ts b/packages/query-engine/src/observability/span-detail.test.ts index e8cdf0208..537e73473 100644 --- a/packages/query-engine/src/observability/span-detail.test.ts +++ b/packages/query-engine/src/observability/span-detail.test.ts @@ -13,6 +13,7 @@ const detailRow = { spanId: SPAN_ID, parentSpanId: "", spanName: "GET /orders", + rawSpanName: "GET", serviceName: "api", spanKind: "Server", durationMs: 12, diff --git a/packages/query-engine/src/span-events.test.ts b/packages/query-engine/src/span-events.test.ts new file mode 100644 index 000000000..6c95e8f7e --- /dev/null +++ b/packages/query-engine/src/span-events.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import { parseSpanEvents } from "./span-events" + +const raw = (overrides: Partial[0]> = {}) => ({ + spanStart: "2026-03-20 08:00:00.000100000", + eventsTimestamp: JSON.stringify(["2026-03-20 08:00:01.250300000", "2026-03-20 08:00:00.000600000"]), + eventsName: JSON.stringify(["exception", "cache.miss"]), + eventsAttributes: JSON.stringify([{ "exception.type": "TypeError" }, { key: "user:1" }]), + ...overrides, +}) + +describe("parseSpanEvents", () => { + it("zips the parallel arrays and sorts by time", () => { + const events = parseSpanEvents(raw()) + expect(events.map((e) => e.name)).toEqual(["cache.miss", "exception"]) + expect(events[0]?.attributes).toEqual({ key: "user:1" }) + expect(events[1]?.attributes).toEqual({ "exception.type": "TypeError" }) + expect(events[1]?.timestamp).toBe("2026-03-20T08:00:01.250Z") + }) + + it("keeps sub-millisecond precision in offsets", () => { + const [first, second] = parseSpanEvents(raw()) + expect(first?.offsetMs).toBeCloseTo(0.5, 9) + expect(second?.offsetMs).toBeCloseTo(1250.2, 9) + }) + + it("tolerates missing names and attributes for an event", () => { + const events = parseSpanEvents( + raw({ eventsName: JSON.stringify(["only"]), eventsAttributes: JSON.stringify([]) }), + ) + expect(events.map((e) => [e.name, e.attributes])).toEqual([ + ["", {}], + ["only", {}], + ]) + }) + + it("reads times and names alone when attributes were not selected", () => { + const { eventsAttributes: _, ...withoutAttributes } = raw() + const events = parseSpanEvents(withoutAttributes) + expect(events.map((e) => [e.name, e.attributes])).toEqual([ + ["cache.miss", {}], + ["exception", {}], + ]) + }) + + it("returns no events for empty or undecodable arrays", () => { + expect(parseSpanEvents(raw({ eventsTimestamp: "[]" }))).toEqual([]) + expect(parseSpanEvents(raw({ eventsTimestamp: "not json" }))).toEqual([]) + }) +}) diff --git a/packages/query-engine/src/span-events.ts b/packages/query-engine/src/span-events.ts new file mode 100644 index 000000000..80e2762b5 --- /dev/null +++ b/packages/query-engine/src/span-events.ts @@ -0,0 +1,77 @@ +// Span events: zip the warehouse's parallel Events arrays into records. +// +// `traces` stores OTel span events as three parallel columns (EventsTimestamp, +// EventsName, EventsAttributes). `spanEventsQuery` returns each as a JSON array +// string; this turns them into one record per event with its offset from the +// span start. Driver-free so the API, MCP and web can share it. + +import { Option, Schema } from "effect" +import { parseWarehouseDateTime, warehouseDateTimeToIso } from "./datetime" + +export interface SpanEvent { + readonly name: string + /** ISO-8601 UTC, millisecond precision. */ + readonly timestamp: string + /** Event time minus span start, in ms with sub-millisecond precision. */ + readonly offsetMs: number + readonly attributes: Readonly> +} + +export interface RawSpanEvents { + readonly spanStart: string + readonly eventsTimestamp: string + readonly eventsName: string + /** Absent when only times and names were read (timeline markers). */ + readonly eventsAttributes?: string +} + +const decodeStrings = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.String))) +const decodeAttributes = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(Schema.Record(Schema.String, Schema.String))), +) + +const DATETIME64_PATTERN = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?/ + +/** + * A DateTime64(9) string as whole epoch ms plus the nanoseconds past that ms. + * `Date.parse` alone would drop everything below a millisecond. + */ +const parseNanos = (value: string): Option.Option<{ readonly ms: number; readonly subMsNanos: number }> => { + const match = DATETIME64_PATTERN.exec(value.trim()) + if (!match) return Option.none() + const [, date, time, fraction = ""] = match + const seconds = parseWarehouseDateTime(`${date} ${time}`) + if (Number.isNaN(seconds)) return Option.none() + const nanos = Number(fraction.padEnd(9, "0")) + return Option.some({ ms: seconds + Math.floor(nanos / 1e6), subMsNanos: nanos % 1e6 }) +} + +/** + * Zip the parallel arrays into events sorted by time. Undecodable input yields + * no events rather than an error: events are supplementary to the span detail. + */ +export const parseSpanEvents = (raw: RawSpanEvents): ReadonlyArray => { + const timestamps = Option.getOrElse(decodeStrings(raw.eventsTimestamp), () => []) + const names = Option.getOrElse(decodeStrings(raw.eventsName), () => []) + const attributes = Option.getOrElse(decodeAttributes(raw.eventsAttributes), () => []) + const start = parseNanos(raw.spanStart) + + return timestamps + .flatMap((value, index) => + Option.match(parseNanos(value), { + onNone: () => [], + onSome: (at) => [ + { + name: names[index] ?? "", + timestamp: warehouseDateTimeToIso(value), + offsetMs: Option.match(start, { + onNone: () => 0, + onSome: (s) => at.ms - s.ms + (at.subMsNanos - s.subMsNanos) / 1e6, + }), + attributes: attributes[index] ?? {}, + }, + ], + }), + ) + .sort((a, b) => a.offsetMs - b.offsetMs) +} diff --git a/packages/ui/src/components/traces/trace-timeline-row.tsx b/packages/ui/src/components/traces/trace-timeline-row.tsx index 64b8ae677..df14694b5 100644 --- a/packages/ui/src/components/traces/trace-timeline-row.tsx +++ b/packages/ui/src/components/traces/trace-timeline-row.tsx @@ -5,12 +5,14 @@ import { cn } from "../../lib/utils" import { getServiceColor } from "../../lib/colors" import { countLabel, formatDuration } from "../../lib/format" import { describeSpan } from "../../lib/span-category" +import { spanEventMarkers, type TimelineSpanEvent } from "../../lib/span-event-markers" +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" import type { TimelineBar } from "./trace-timeline-types" import { DEPTH_INDENT, ROW_HEIGHT } from "./trace-timeline-types" /** Indent stops growing past this depth so deep traces keep room for the label. */ const MAX_INDENT_DEPTH = 12 -import { TONE_TEXT } from "../../lib/tone" +import { TONE_FILL, TONE_TEXT } from "../../lib/tone" interface TraceTimelineRowProps { bar: TimelineBar @@ -25,6 +27,8 @@ interface TraceTimelineRowProps { matched: boolean /** A single-service trace repeats one name down every row — the header already said it. */ showService: boolean + /** Span events, drawn as ticks along the bar. */ + events?: ReadonlyArray onSelect: (spanId: string) => void /** `wholeSubtree` comes from an Alt/Option-click. */ onToggleCollapse: (spanId: string, wholeSubtree: boolean) => void @@ -56,6 +60,7 @@ function TraceTimelineRowImpl({ dimmed, matched, showService, + events, onSelect, onToggleCollapse, onZoomSpan, @@ -229,6 +234,15 @@ function TraceTimelineRowImpl({
    + {events && events.length > 0 && ( + + )} + {/* Clipping chevrons, pinned to the cell edges (Jaeger). Toggled by the decoration pass — CSS can't tell whether the clamp above actually bit. */} + spanStartMs: number + durationMs: number + onHover: TraceTimelineRowProps["onHover"] +}) { + const markers = React.useMemo( + () => spanEventMarkers(events, spanStartMs, durationMs), + [events, spanStartMs, durationMs], + ) + return ( +
    + {markers.map((marker, index) => ( + + onHover(null, null)} + onMouseMove={(e) => e.stopPropagation()} + /> + } + > + + + + + {marker.name || "(unnamed event)"} + {" "} + + {marker.offsetLabel} + + + + ))} +
    + ) +} + export const TraceTimelineRow = React.memo(TraceTimelineRowImpl) diff --git a/packages/ui/src/components/traces/trace-timeline.tsx b/packages/ui/src/components/traces/trace-timeline.tsx index b1d60aa95..44cbf7636 100644 --- a/packages/ui/src/components/traces/trace-timeline.tsx +++ b/packages/ui/src/components/traces/trace-timeline.tsx @@ -61,6 +61,7 @@ export function TraceTimeline() { onSelectSpan, colorBy, setColorBy, + spanEvents, } = useTraceView() // Surfaced in the toolbar: a correction the user cannot see in the bars themselves // should still be visible as a fact about the trace. @@ -459,9 +460,7 @@ export function TraceTimeline() { }, [virtualRangeKey, repaintRowDecorations]) if (rootSpans.length === 0) { - return ( - No spans found for this trace - ) + return No spans found for this trace } return ( @@ -633,6 +632,7 @@ export function TraceTimeline() { dimmed={isSearchActive && !matched} matched={matched} showService={services.length > 1} + events={spanEvents?.get(id)} onSelect={handleSelect} onToggleCollapse={handleToggleCollapse} onZoomSpan={handleZoomSpan} @@ -667,32 +667,20 @@ export function TraceTimeline() { {/* Pointer/keyboard hints are meaningless on touch — the legend takes the whole bar there. */}
    - - Drag - {" "} - zoom + Drag zoom - - W A S D - {" "} - navigate + W A S D navigate - - / - {" "} - search + / search
    {/* Legend: scrolls sideways on narrow screens rather than wrapping the bar taller. */} diff --git a/packages/ui/src/components/traces/trace-view-context.tsx b/packages/ui/src/components/traces/trace-view-context.tsx index c140425f6..ad221c052 100644 --- a/packages/ui/src/components/traces/trace-view-context.tsx +++ b/packages/ui/src/components/traces/trace-view-context.tsx @@ -2,6 +2,7 @@ import * as React from "react" import type { ReactNode } from "react" import type { SpanNode } from "../../lib/types" import type { ColorByField } from "./color-by" +import type { SpanEventsBySpanId } from "../../lib/span-event-markers" interface TraceViewContextValue { rootSpans: SpanNode[] @@ -12,6 +13,8 @@ interface TraceViewContextValue { onSelectSpan?: (span: SpanNode) => void colorBy: ColorByField setColorBy: (next: ColorByField) => void + /** Loaded after the hierarchy; absent until then, and for views that never fetch them. */ + spanEvents?: SpanEventsBySpanId } const TraceViewContext = React.createContext(null) @@ -28,6 +31,7 @@ export function TraceViewProvider({ children, ...value }: TraceViewContextValue value.onSelectSpan, value.colorBy, value.setColorBy, + value.spanEvents, ], ) return {children} diff --git a/packages/ui/src/components/traces/trace-view-tabs.tsx b/packages/ui/src/components/traces/trace-view-tabs.tsx index 217d4baa6..26f218eba 100644 --- a/packages/ui/src/components/traces/trace-view-tabs.tsx +++ b/packages/ui/src/components/traces/trace-view-tabs.tsx @@ -8,6 +8,7 @@ import { TraceFlowView } from "./flow-view" import { TraceViewProvider } from "./trace-view-context" import { DEFAULT_COLOR_BY, type ColorByField } from "./color-by" import type { SpanNode, Span } from "../../lib/types" +import type { SpanEventsBySpanId } from "../../lib/span-event-markers" export type TraceView = "waterfall" | "timeline" | "flow" @@ -26,6 +27,8 @@ interface TraceViewTabsProps { /** Controlled active tab. Omit to let the tabs own it, starting on the timeline. */ view?: TraceView onViewChange?: (view: TraceView) => void + /** Event markers for the timeline bars. */ + spanEvents?: SpanEventsBySpanId } export function TraceViewTabs({ @@ -38,6 +41,7 @@ export function TraceViewTabs({ onSelectSpan, view, onViewChange, + spanEvents, }: TraceViewTabsProps) { // _spans is reserved for future Flow view implementation const [colorBy, setColorBy] = React.useState(DEFAULT_COLOR_BY) @@ -52,6 +56,7 @@ export function TraceViewTabs({ onSelectSpan={onSelectSpan} colorBy={colorBy} setColorBy={setColorBy} + spanEvents={spanEvents} > { + it("places each event at its time on the trace axis", () => { + const markers = spanEventMarkers( + [ + { name: "cache.miss", offsetMs: 25 }, + { name: "exception", offsetMs: 75 }, + ], + 1000, + 100, + ) + expect(markers.map((m) => [m.name, m.atMs, m.isException])).toEqual([ + ["cache.miss", 1025, false], + ["exception", 1075, true], + ]) + }) + + it("keeps the event time of a span that runs far past the visible window", () => { + // Zoomed in on 0-10 ms: the bar of this 0-10 s span is clamped to the view, the + // marker must still sit at 5 ms, not at the middle of the clamped box. + const [marker] = spanEventMarkers([{ name: "retry", offsetMs: 5 }], 0, 10_000) + expect(marker?.atMs).toBe(5) + }) + + it("pins events outside the span to the nearest edge", () => { + const markers = spanEventMarkers( + [ + { name: "early", offsetMs: -3 }, + { name: "late", offsetMs: 140 }, + ], + 1000, + 100, + ) + expect(markers.map((m) => m.atMs)).toEqual([1000, 1100]) + expect(markers.map((m) => m.offsetMs)).toEqual([-3, 140]) + expect(markers.map((m) => m.offsetLabel)).toEqual(["-3.0ms", "+140.0ms"]) + }) + + it("puts every event at the start of a zero-duration span", () => { + expect(spanEventMarkers([{ name: "log", offsetMs: 0.2 }], 40, 0).map((m) => m.atMs)).toEqual([40]) + }) +}) diff --git a/packages/ui/src/lib/span-event-markers.ts b/packages/ui/src/lib/span-event-markers.ts new file mode 100644 index 000000000..dcb15be32 --- /dev/null +++ b/packages/ui/src/lib/span-event-markers.ts @@ -0,0 +1,42 @@ +// Span event markers on the trace timeline: where along its bar each event sits. + +import { formatDuration } from "./format" + +/** One OTel span event as the timeline needs it: no attributes. */ +export interface TimelineSpanEvent { + readonly name: string + /** Event time minus span start, in ms. */ + readonly offsetMs: number +} + +/** Timeline events keyed by span id; spans without events are absent. */ +export type SpanEventsBySpanId = ReadonlyMap> + +export interface SpanEventMarker { + readonly name: string + readonly offsetMs: number + /** Trace-relative time the marker sits at, like the bar's `--b0`/`--b1`; kept inside the span. */ + readonly atMs: number + /** `+12ms`, or `-3ms` for an event stamped before its span started (clock skew). */ + readonly offsetLabel: string + readonly isException: boolean +} + +/** + * Place events on the trace's time axis, so they are positioned like the bar itself rather + * than inside its viewport-clamped box. Offsets outside the span (clock skew, or an event + * recorded after `end()`) pin to the nearest edge rather than escaping it. + */ +export function spanEventMarkers( + events: ReadonlyArray, + spanStartMs: number, + durationMs: number, +): ReadonlyArray { + return events.map((event) => ({ + name: event.name, + offsetMs: event.offsetMs, + atMs: spanStartMs + Math.min(Math.max(durationMs, 0), Math.max(0, event.offsetMs)), + offsetLabel: `${event.offsetMs < 0 ? "-" : "+"}${formatDuration(Math.abs(event.offsetMs))}`, + isException: event.name === "exception", + })) +}