diff --git a/.changeset/event-write-stso-spans.md b/.changeset/event-write-stso-spans.md new file mode 100644 index 0000000000..7569167b32 --- /dev/null +++ b/.changeset/event-write-stso-spans.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Add the Workflow client version, step-to-step overhead, and active latency optimizations to terminal event-write client spans for service and version latency analysis. diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 93dd6ba96f..8daf076600 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -62,12 +62,16 @@ import { deserializeStep, StepWireSchema } from './steps.js'; import { ErrorType, NetworkProtocolName, + StepLatencyOptimizations, + StepStsoMs, + WorkflowClientVersion, WorkflowEventsTransport, WorkflowEventType, WorkflowWsRequestId, WorkflowWsUrl, } from './telemetry.js'; import { type APIConfig, getHttpConfig, getHttpUrl } from './utils.js'; +import { version } from './version.js'; import type { WsFrameReply } from './ws-transport.js'; import { isWsEventsTransportEnabled } from './ws-transport-enabled.js'; @@ -99,7 +103,7 @@ async function fetchV4( init: { method: string; headers: Headers; body?: Uint8Array }, config: APIConfig | undefined, opName: string, - attributes?: Record + attributes?: Record ): Promise { const dispatcher = getEventsDispatcher(config); return instrumentedFetch({ @@ -709,6 +713,11 @@ async function postWorkflowRunEventV4( { ...WorkflowEventsTransport('http'), ...WorkflowEventType(input.eventType), + ...WorkflowClientVersion(`@workflow/world-vercel/${version}`), + ...(input.stso !== undefined ? StepStsoMs(input.stso) : {}), + ...(input.optimizations !== undefined + ? StepLatencyOptimizations(input.optimizations) + : {}), } ); } @@ -930,6 +939,11 @@ async function postEventFrameOverWs( attributes: { ...WorkflowEventsTransport('ws'), ...WorkflowEventType(input.eventType), + ...WorkflowClientVersion(`@workflow/world-vercel/${version}`), + ...(input.stso !== undefined ? StepStsoMs(input.stso) : {}), + ...(input.optimizations !== undefined + ? StepLatencyOptimizations(input.optimizations) + : {}), ...NetworkProtocolName('websocket'), ...WorkflowWsUrl(wsUrl), }, diff --git a/packages/world-vercel/src/http-core.ts b/packages/world-vercel/src/http-core.ts index 9e7b4fd35c..371c728adf 100644 --- a/packages/world-vercel/src/http-core.ts +++ b/packages/world-vercel/src/http-core.ts @@ -364,7 +364,7 @@ export interface HttpClientSpanOptions { */ spanName?: string; /** Extra attributes merged on top of the standard HTTP attributes. */ - attributes?: Record; + attributes?: Record; } /** diff --git a/packages/world-vercel/src/telemetry.ts b/packages/world-vercel/src/telemetry.ts index 0f75669eb2..948edae3dd 100644 --- a/packages/world-vercel/src/telemetry.ts +++ b/packages/world-vercel/src/telemetry.ts @@ -282,6 +282,19 @@ export const WorkflowEventType = SemanticConvention( 'workflow.event.type' ); +/** Version of the Workflow client package issuing the request. */ +export const WorkflowClientVersion = SemanticConvention( + 'workflow.client.version' +); + +/** Client-measured step-to-step overhead in milliseconds. */ +export const StepStsoMs = SemanticConvention('step.stso_ms'); + +/** Runtime optimizations active for the step latency measurement. */ +export const StepLatencyOptimizations = SemanticConvention( + 'step.latency_optimizations' +); + /** * The socket a WS event write actually travelled over * (workflow.events.ws.url). `url.full` names the v4 REST endpoint the frame is diff --git a/packages/world-vercel/src/ws-transport-spans.test.ts b/packages/world-vercel/src/ws-transport-spans.test.ts index 9576f7eeba..5990a18f5e 100644 --- a/packages/world-vercel/src/ws-transport-spans.test.ts +++ b/packages/world-vercel/src/ws-transport-spans.test.ts @@ -46,6 +46,7 @@ import { V4_FRAME_CONTENT_TYPE, } from './frames.js'; import { WORKFLOW_SERVER_URL_OVERRIDE } from './utils.js'; +import { version } from './version.js'; vi.mock('@vercel/oidc', () => ({ getVercelOidcToken: vi.fn().mockRejectedValue(new Error('no OIDC')), @@ -148,6 +149,8 @@ const input = { eventType: 'step_completed', specVersion: 2, correlationId: 'step_1', + stso: 468, + optimizations: ['lazyStepStart'], } as const; /** The materialized CBOR body a `step_completed` write answers with. */ @@ -284,6 +287,13 @@ describe('per-write client span', () => { expect(span.attributes['network.protocol.name']).toBe('websocket'); expect(span.attributes['workflow.events.ws.url']).toBe(WS_URL); expect(span.attributes['workflow.event.type']).toBe('step_completed'); + expect(span.attributes['workflow.client.version']).toBe( + `@workflow/world-vercel/${version}` + ); + expect(span.attributes['step.stso_ms']).toBe(468); + expect(span.attributes['step.latency_optimizations']).toEqual([ + 'lazyStepStart', + ]); }); it('carries the reqId that joins it to the server log line for the same frame', async () => { @@ -521,6 +531,13 @@ describe('transport parity', () => { expect(span.attributes['http.request.method']).toBe('POST'); expect(span.attributes['workflow.event.type']).toBe('step_completed'); expect(span.attributes['workflow.events.transport']).toBe('http'); + expect(span.attributes['workflow.client.version']).toBe( + `@workflow/world-vercel/${version}` + ); + expect(span.attributes['step.stso_ms']).toBe(468); + expect(span.attributes['step.latency_optimizations']).toEqual([ + 'lazyStepStart', + ]); expect(span.attributes['network.protocol.name']).toBeUndefined(); agent.assertNoPendingInterceptors(); });