From 3f2b4f047927164e435c3472001f8559ac0f290d Mon Sep 17 00:00:00 2001 From: Jeremy Morrell Date: Fri, 31 Jul 2026 14:39:14 -0700 Subject: [PATCH 1/3] Add Span.recordException tracing support --- ...ng-log-attribution-instrumentation-test.js | 48 ++++++++++++ .../tracing/tracing-log-attribution-test.js | 28 +++++++ src/cloudflare/internal/tracing.d.ts | 24 ++++++ src/workerd/api/tracing.c++ | 74 +++++++++++++++++++ src/workerd/api/tracing.h | 22 +++++- src/workerd/io/trace.c++ | 10 +++ src/workerd/io/trace.h | 6 ++ src/workerd/io/tracer.c++ | 66 +++++++++++++---- src/workerd/io/tracer.h | 22 ++++++ src/workerd/server/server.c++ | 13 ++++ 10 files changed, 299 insertions(+), 14 deletions(-) diff --git a/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js b/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js index 0f17cbe224c..629e12e0362 100644 --- a/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js +++ b/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js @@ -166,6 +166,16 @@ function findLog(inv, substring) { return matches[0]; } +function findException(inv, message) { + const matches = inv.exceptions.filter((e) => e.message === message); + assert.strictEqual( + matches.length, + 1, + `Expected exactly one exception with message "${message}" in invocation ${inv.invocationId}, got ${matches.length}` + ); + return matches[0]; +} + export const validate = { async test() { // Wait for every invocation's `outcome` event to arrive. @@ -285,6 +295,44 @@ export const validate = { ); } + // recordException() targets the receiver span, even when a different span is active. + { + const { inv, span: active } = findInvocationBySpanName( + 'record-exception-active' + ); + const receiver = Array.from(inv.spans.values()).find( + (span) => span.name === 'record-exception-receiver' + ); + assert.ok(receiver, 'expected the receiver span'); + + const error = findException(inv, 'recorded-error'); + assert.strictEqual(error.name, 'Error'); + assert.strictEqual(error.spanContextSpanId, receiver.spanId); + assert.notStrictEqual(error.spanContextSpanId, active.spanId); + + const coded = findException(inv, 'recorded-code'); + assert.strictEqual(coded.name, '42'); + assert.strictEqual(coded.spanContextSpanId, receiver.spanId); + + const string = findException(inv, 'recorded-string'); + assert.strictEqual(string.name, ''); + assert.strictEqual(string.spanContextSpanId, receiver.spanId); + + const messageOnly = findException(inv, 'recorded-message'); + assert.strictEqual(messageOnly.name, ''); + assert.strictEqual(messageOnly.spanContextSpanId, receiver.spanId); + + const zeroCode = findException(inv, 'recorded-zero-code'); + assert.strictEqual(zeroCode.name, 'FallbackError'); + assert.strictEqual(zeroCode.spanContextSpanId, receiver.spanId); + } + + // Calls after end() must not emit an exception event. + { + const { inv } = findInvocationBySpanName('record-exception-ended'); + assert.strictEqual(inv.exceptions.length, 0); + } + console.log('All tracing-log-attribution tests passed!'); }, }; diff --git a/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js b/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js index 786d76329c8..8f721f6408f 100644 --- a/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js +++ b/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js @@ -81,3 +81,31 @@ export const diagnosticsChannelInsideEnterSpan = { }); }, }; + +export const recordExceptionUsesReceiverSpan = { + async test(ctrl, env, ctx) { + const { withSpan } = env.tracingTest; + withSpan('record-exception-active', (active) => { + active.setAttribute('case', 'recordExceptionUsesReceiverSpan'); + const detached = ctx.tracing.startSpan('record-exception-receiver'); + detached.recordException(new Error('recorded-error')); + detached.recordException({ code: 42, message: 'recorded-code' }); + detached.recordException('recorded-string'); + detached.recordException({ message: 'recorded-message' }); + detached.recordException({ + code: 0, + name: 'FallbackError', + message: 'recorded-zero-code', + }); + detached.end(); + }); + }, +}; + +export const recordExceptionAfterEndIsIgnored = { + async test(ctrl, env, ctx) { + const span = ctx.tracing.startSpan('record-exception-ended'); + span.end(); + span.recordException('ignored-after-end'); + }, +}; diff --git a/src/cloudflare/internal/tracing.d.ts b/src/cloudflare/internal/tracing.d.ts index 10b397df717..b7b6a071da0 100644 --- a/src/cloudflare/internal/tracing.d.ts +++ b/src/cloudflare/internal/tracing.d.ts @@ -9,6 +9,27 @@ interface SpanAttributes { [key: string]: SpanValue | undefined; } +type SpanException = + | string + | { + code: string | number; + name?: string; + message?: string; + stack?: string; + } + | { + code?: string | number; + name: string; + message?: string; + stack?: string; + } + | { + code?: string | number; + name?: string; + message: string; + stack?: string; + }; + declare class Span { // Returns true if this span will be recorded to the tracing system. False when the // current async context is not being traced, or when the span has already been submitted. @@ -21,6 +42,9 @@ declare class Span { // Sets multiple attributes on the span. Attributes with undefined values are ignored. setAttributes(attributes: SpanAttributes): this; + // Records an exception event on the span. Calls after the span has ended are ignored. + recordException(exception: SpanException): void; + // Ends the span and submits its attributes to the tracing system. Idempotent. end(): void; } diff --git a/src/workerd/api/tracing.c++ b/src/workerd/api/tracing.c++ index d30079c9968..78710e5a4a9 100644 --- a/src/workerd/api/tracing.c++ +++ b/src/workerd/api/tracing.c++ @@ -105,6 +105,23 @@ void SpanImpl::setAttribute(kj::String key, kj::Maybe maybeValue) { // If value is kj::none the attribute is left unset (undefined on the JS side). } +void SpanImpl::recordException(kj::String name, kj::String message, kj::Maybe stack) { + if (!builder.isObserved()) { + return; + } + + size_t valueSize = name.size() + message.size(); + KJ_IF_SOME(s, stack) { + valueSize += s.size(); + } + bytesUsed += valueSize; + if (bytesUsed > MAX_SPAN_BYTES) { + setSpanDataLimitError("exception", name, valueSize); + return; + } + builder.recordException(kj::mv(name), kj::mv(message), kj::mv(stack)); +} + void SpanImpl::setSpanDataLimitError(kj::StringPtr itemKind, kj::StringPtr name, size_t valueSize) { if (!builder.isObserved()) { return; @@ -162,6 +179,63 @@ jsg::Ref Span::setAttributes(jsg::Lock& js, jsg::Dict& exceptionHandler) { + if (!getIsTraced()) { + return; + } + + kj::String name; + kj::String message; + kj::Maybe stack; + auto handle = exception.getHandle(js); + if (handle->IsString()) { + message = jsg::JsValue(handle).toString(js); + } else if (handle->IsObject()) { + auto data = KJ_REQUIRE_NONNULL(exceptionHandler.tryUnwrap(js, handle)); + KJ_IF_SOME(code, data.code) { + KJ_SWITCH_ONEOF(code) { + KJ_CASE_ONEOF(s, kj::String) { + if (s.size() > 0) { + name = kj::mv(s); + } + } + KJ_CASE_ONEOF(n, double) { + if (n != 0 && n == n) { + name = kj::str(n); + } + } + } + } + if (name.size() == 0) { + KJ_IF_SOME(n, data.name) { + name = kj::mv(n); + } + } + KJ_IF_SOME(m, data.message) { + message = kj::mv(m); + } + KJ_IF_SOME(s, data.stack) { + stack = kj::mv(s); + } + + if (name.size() == 0 && message.size() == 0) { + return; + } + } else { + return; + } + + KJ_SWITCH_ONEOF(impl) { + KJ_CASE_ONEOF(s, kj::Own) { + s->recordException(kj::mv(name), kj::mv(message), kj::mv(stack)); + } + KJ_CASE_ONEOF(s, IoOwn) { + s->recordException(kj::mv(name), kj::mv(message), kj::mv(stack)); + } + } +} + void Span::end() { KJ_SWITCH_ONEOF(impl) { KJ_CASE_ONEOF(s, kj::Own) { diff --git a/src/workerd/api/tracing.h b/src/workerd/api/tracing.h index d2928277673..7f2c8b87711 100644 --- a/src/workerd/api/tracing.h +++ b/src/workerd/api/tracing.h @@ -28,6 +28,15 @@ constexpr size_t MAX_USER_OPERATION_NAME_BYTES = 64; // The types allowed for tag and log values from JavaScript. using TagValue = kj::OneOf; +struct ExceptionData { + jsg::Optional> code; + jsg::Optional name; + jsg::Optional message; + jsg::Optional stack; + + JSG_STRUCT(code, name, message, stack); +}; + // Refcounted wrapper around workerd::SpanBuilder, exposing the JS Span surface: bytes-used // limit enforcement and JS-side TagValue forwarding. Span lifecycle (onOpen/onClose) is // delegated to SpanBuilder. @@ -58,6 +67,8 @@ class SpanImpl final: public kj::Refcounted { // Sets a single attribute on the span. If value is kj::none, the attribute is not set. void setAttribute(kj::String key, kj::Maybe maybeValue); + void recordException(kj::String name, kj::String message, kj::Maybe stack); + private: workerd::SpanBuilder builder; @@ -90,6 +101,9 @@ class Span: public jsg::Object { // Sets each attribute in `attributes` as if by calling setAttribute(). jsg::Ref setAttributes(jsg::Lock& js, jsg::Dict> attributes); + void recordException( + jsg::Lock& js, jsg::Value exception, const jsg::TypeHandler& exceptionHandler); + // Ends the span and submits its content to the tracing system. Idempotent. void end(); @@ -98,6 +112,7 @@ class Span: public jsg::Object { JSG_METHOD(setAttribute); JSG_METHOD(setAttributes); + JSG_METHOD(recordException); JSG_METHOD(end); JSG_TS_OVERRIDE({ @@ -105,6 +120,10 @@ class Span: public jsg::Object { setAttributes( attributes: Record ): this; + recordException(exception: string + | { code: string | number; name?: string; message?: string; stack?: string } + | { code?: string | number; name: string; message?: string; stack?: string } + | { code?: string | number; name?: string; message: string; stack?: string }): void; }); } @@ -212,4 +231,5 @@ kj::Own getInternalTracingModuleBundle(auto featureF } // namespace workerd::api -#define EW_TRACING_ISOLATE_TYPES api::Tracing, api::user_tracing::Span +#define EW_TRACING_ISOLATE_TYPES \ + api::Tracing, api::user_tracing::Span, api::user_tracing::ExceptionData diff --git a/src/workerd/io/trace.c++ b/src/workerd/io/trace.c++ index 8d885a1811c..be887080e1a 100644 --- a/src/workerd/io/trace.c++ +++ b/src/workerd/io/trace.c++ @@ -1924,6 +1924,16 @@ void SpanBuilder::addLog(kj::Date timestamp, kj::ConstString key, TagValue value } } +void SpanBuilder::recordException( + kj::String name, kj::String message, kj::Maybe stack) { + if (span == kj::none) { + return; + } + KJ_IF_SOME(o, observer) { + o->onException(o->getTime(), kj::mv(name), kj::mv(message), kj::mv(stack)); + } +} + void TraceContext::setTag(kj::ConstString key, SpanBuilder::TagInitValue value) { if (!isObserved()) { return; diff --git a/src/workerd/io/trace.h b/src/workerd/io/trace.h index c0aca4514c9..d8e62f5289f 100644 --- a/src/workerd/io/trace.h +++ b/src/workerd/io/trace.h @@ -1254,6 +1254,9 @@ class SpanBuilder { // duplicate keys. void addLog(kj::Date timestamp, kj::ConstString key, TagValue value); + // Records an exception associated with this span. Calls after end() are ignored. + void recordException(kj::String name, kj::String message, kj::Maybe stack); + private: kj::Maybe> observer; // The under-construction span, or null if the span has ended. @@ -1292,6 +1295,9 @@ class SpanObserver: public kj::Refcounted { // the observer takes ownership. virtual void onClose(kj::Date endTime, Span::TagMap&& tags, kj::Vector&& logs) = 0; + virtual void onException( + kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack) {} + // Called when the operation name is changed after the span was opened (via // SpanBuilder::setOperationName()). Observers that eagerly stream the open event should handle // this; others may simply update their buffered state. Default implementation is a no-op. diff --git a/src/workerd/io/tracer.c++ b/src/workerd/io/tracer.c++ index e0e1d32a6b5..58a121414af 100644 --- a/src/workerd/io/tracer.c++ +++ b/src/workerd/io/tracer.c++ @@ -37,6 +37,28 @@ tracing::Attribute::Value cloneAttributeValue(const tracing::Attribute::Value& v } KJ_UNREACHABLE; } + +void reportExceptionToTailStream(tracing::TailStreamWriter& writer, + const tracing::InvocationSpanContext& context, + kj::Date timestamp, + kj::StringPtr name, + kj::StringPtr message, + kj::Maybe stack) { + auto truncatedName = name.first(kj::min(name.size(), MAX_TRACE_BYTES)); + auto truncatedMessage = + message.first(kj::min(message.size(), MAX_TRACE_BYTES - truncatedName.size())); + kj::Maybe truncatedStack; + size_t truncatedStackSize = 0; + KJ_IF_SOME(s, stack) { + truncatedStackSize = + kj::min(s.size(), MAX_TRACE_BYTES - truncatedName.size() - truncatedMessage.size()); + truncatedStack = kj::heapString(s.first(truncatedStackSize)); + } + writer.report(context, + {tracing::Exception( + timestamp, kj::str(truncatedName), kj::str(truncatedMessage), kj::mv(truncatedStack))}, + timestamp, truncatedName.size() + truncatedMessage.size() + truncatedStackSize); +} } // namespace kj::Promise> WorkerTracer::onComplete() { @@ -255,21 +277,11 @@ void WorkerTracer::addException(const tracing::InvocationSpanContext& context, messageSize += s.size(); } KJ_IF_SOME(writer, maybeTailStreamWriter) { - auto maybeTruncatedName = name.first(kj::min(name.size(), MAX_TRACE_BYTES)); - auto maybeTruncatedMessage = - message.first(kj::min(message.size(), MAX_TRACE_BYTES - maybeTruncatedName.size())); - kj::Maybe maybeTruncatedStack; - auto maybeTruncatedStackSize = 0; + kj::Maybe stackPtr; KJ_IF_SOME(s, stack) { - maybeTruncatedStackSize = kj::min( - s.size(), MAX_TRACE_BYTES - maybeTruncatedName.size() - maybeTruncatedMessage.size()); - maybeTruncatedStack = kj::heapString(s.first(maybeTruncatedStackSize)); + stackPtr = s; } - writer->report(context, - {tracing::Exception(timestamp, kj::str(maybeTruncatedName), kj::str(maybeTruncatedMessage), - kj::mv(maybeTruncatedStack))}, - timestamp, - maybeTruncatedName.size() + maybeTruncatedMessage.size() + maybeTruncatedStackSize); + reportExceptionToTailStream(*writer, context, timestamp, name, message, stackPtr); } if (trace->exceededExceptionLimit) { @@ -287,6 +299,27 @@ void WorkerTracer::addException(const tracing::InvocationSpanContext& context, } } +void WorkerTracer::addSpanException(tracing::SpanId spanId, + kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) { + if (pipelineLogLevel == PipelineLogLevel::NONE) { + return; + } + + auto& writer = KJ_UNWRAP_OR_RETURN(maybeTailStreamWriter); + auto& topLevelContext = KJ_ASSERT_NONNULL(topLevelInvocationSpanContext); + auto context = tracing::InvocationSpanContext(topLevelContext.getTraceId(), + topLevelContext.getInvocationId(), spanId, topLevelContext.getTraceFlags()); + + kj::Maybe stackPtr; + KJ_IF_SOME(s, stack) { + stackPtr = s; + } + reportExceptionToTailStream(*writer, context, timestamp, name, message, stackPtr); +} + void WorkerTracer::addDiagnosticChannelEvent(const tracing::InvocationSpanContext& context, kj::Date timestamp, kj::String channel, @@ -621,6 +654,13 @@ void UserSpanObserver::onOpen(kj::ConstString operationName, kj::Date startTime) } } +void UserSpanObserver::onException( + kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack) { + if (wasAccepted) { + submitter->submitSpanException(spanId, timestamp, kj::mv(name), kj::mv(message), kj::mv(stack)); + } +} + // Provide I/O time to the tracing system for user spans. kj::Date UserSpanObserver::getTime() { return IoContext::current().now(); diff --git a/src/workerd/io/tracer.h b/src/workerd/io/tracer.h index 6070deae816..3a6da17118b 100644 --- a/src/workerd/io/tracer.h +++ b/src/workerd/io/tracer.h @@ -62,6 +62,13 @@ class BaseTracer: public kj::Refcounted { kj::String message, kj::Maybe stack) = 0; + // Records an exception event on a span without treating the invocation as having thrown. + virtual void addSpanException(tracing::SpanId spanId, + kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) = 0; + virtual void addDiagnosticChannelEvent(const tracing::InvocationSpanContext& context, kj::Date timestamp, kj::String channel, @@ -165,6 +172,11 @@ class WorkerTracer final: public BaseTracer { kj::String name, kj::String message, kj::Maybe stack) override; + void addSpanException(tracing::SpanId spanId, + kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) override; void addDiagnosticChannelEvent(const tracing::InvocationSpanContext& context, kj::Date timestamp, kj::String channel, @@ -235,6 +247,12 @@ class SpanSubmitter: public kj::Refcounted { virtual void submitSpanClose( tracing::SpanId spanId, kj::Date startTime, kj::Date endTime, Span::TagMap&& tags) = 0; + virtual void submitSpanException(tracing::SpanId spanId, + kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) = 0; + virtual tracing::SpanId makeSpanId() = 0; }; @@ -276,6 +294,10 @@ class UserSpanObserver final: public SpanObserver { kj::Own newChildFromUserCode() override; void onOpen(kj::ConstString operationName, kj::Date startTime) override; void onClose(kj::Date endTime, Span::TagMap&& tags, kj::Vector&& logs) override; + void onException(kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) override; kj::Date getTime() override; kj::Maybe toSpanContext() override; tracing::SpanId getSpanId() override; diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index 594be83e177..db6870d692c 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -3199,6 +3199,19 @@ class SequentialSpanSubmitter final: public SpanSubmitter { }); } + void submitSpanException(tracing::SpanId spanId, + kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack) override { + weakTracer->runIfAlive([&](BaseTracer& tracer) { + if (isPredictableModeForTest()) { + timestamp = kj::UNIX_EPOCH; + } + tracer.addSpanException(spanId, timestamp, kj::mv(name), kj::mv(message), kj::mv(stack)); + }); + } + bool submitSpanOpen(tracing::SpanId spanId, tracing::SpanId parentSpanId, kj::ConstString operationName, From be02098b05233db909ee67cbd93521ab59c3df79 Mon Sep 17 00:00:00 2001 From: Jeremy Morrell Date: Thu, 6 Aug 2026 10:22:55 -0700 Subject: [PATCH 2/3] Align recorded exceptions with OpenTelemetry --- ...ng-log-attribution-instrumentation-test.js | 26 ++++++++- .../tracing/tracing-log-attribution-test.js | 2 + src/cloudflare/internal/tracing.d.ts | 50 +++++++++-------- src/workerd/api/tracing.c++ | 49 ++++++++++------- src/workerd/api/tracing.h | 7 ++- src/workerd/io/trace-stream.c++ | 10 ++++ src/workerd/io/trace-test.c++ | 17 ++++++ src/workerd/io/trace.c++ | 53 ++++++++++++++++--- src/workerd/io/trace.h | 22 ++++++-- src/workerd/io/tracer.c++ | 44 ++++++++++----- src/workerd/io/tracer.h | 4 ++ src/workerd/io/worker-interface.capnp | 5 ++ src/workerd/server/server.c++ | 4 +- types/defines/trace.d.ts | 1 + 14 files changed, 225 insertions(+), 69 deletions(-) diff --git a/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js b/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js index 629e12e0362..84b70731d06 100644 --- a/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js +++ b/src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js @@ -87,6 +87,7 @@ export default { break; case 'exception': inv.exceptions.push({ + code: event.event.code, name: event.event.name, message: event.event.message, spanContextSpanId: currentSpanId, @@ -176,6 +177,16 @@ function findException(inv, message) { return matches[0]; } +function findExceptionByCode(inv, code) { + const matches = inv.exceptions.filter((e) => e.code === code); + assert.strictEqual( + matches.length, + 1, + `Expected exactly one exception with code "${code}" in invocation ${inv.invocationId}, got ${matches.length}` + ); + return matches[0]; +} + export const validate = { async test() { // Wait for every invocation's `outcome` event to arrive. @@ -311,7 +322,8 @@ export const validate = { assert.notStrictEqual(error.spanContextSpanId, active.spanId); const coded = findException(inv, 'recorded-code'); - assert.strictEqual(coded.name, '42'); + assert.strictEqual(coded.code, 42); + assert.strictEqual(coded.name, ''); assert.strictEqual(coded.spanContextSpanId, receiver.spanId); const string = findException(inv, 'recorded-string'); @@ -323,8 +335,20 @@ export const validate = { assert.strictEqual(messageOnly.spanContextSpanId, receiver.spanId); const zeroCode = findException(inv, 'recorded-zero-code'); + assert.strictEqual(zeroCode.code, 0); assert.strictEqual(zeroCode.name, 'FallbackError'); assert.strictEqual(zeroCode.spanContextSpanId, receiver.spanId); + + const codeOnly = findExceptionByCode(inv, 'CODE_ONLY'); + assert.strictEqual(codeOnly.name, ''); + assert.strictEqual(codeOnly.message, ''); + assert.strictEqual(codeOnly.spanContextSpanId, receiver.spanId); + + assert.strictEqual( + inv.exceptions.length, + 6, + 'stack-only objects do not satisfy the Exception union' + ); } // Calls after end() must not emit an exception event. diff --git a/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js b/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js index 8f721f6408f..2b8688d5e7a 100644 --- a/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js +++ b/src/cloudflare/internal/test/tracing/tracing-log-attribution-test.js @@ -97,6 +97,8 @@ export const recordExceptionUsesReceiverSpan = { name: 'FallbackError', message: 'recorded-zero-code', }); + detached.recordException({ code: 'CODE_ONLY' }); + detached.recordException({ stack: 'ignored-stack-only' }); detached.end(); }); }, diff --git a/src/cloudflare/internal/tracing.d.ts b/src/cloudflare/internal/tracing.d.ts index b7b6a071da0..d1e659c5c57 100644 --- a/src/cloudflare/internal/tracing.d.ts +++ b/src/cloudflare/internal/tracing.d.ts @@ -9,26 +9,32 @@ interface SpanAttributes { [key: string]: SpanValue | undefined; } -type SpanException = - | string - | { - code: string | number; - name?: string; - message?: string; - stack?: string; - } - | { - code?: string | number; - name: string; - message?: string; - stack?: string; - } - | { - code?: string | number; - name?: string; - message: string; - stack?: string; - }; +interface ExceptionWithCode { + code: string | number; + name?: string; + message?: string; + stack?: string; +} + +interface ExceptionWithMessage { + code?: string | number; + message: string; + name?: string; + stack?: string; +} + +interface ExceptionWithName { + code?: string | number; + message?: string; + name: string; + stack?: string; +} + +type Exception = + | ExceptionWithCode + | ExceptionWithMessage + | ExceptionWithName + | string; declare class Span { // Returns true if this span will be recorded to the tracing system. False when the @@ -43,7 +49,7 @@ declare class Span { setAttributes(attributes: SpanAttributes): this; // Records an exception event on the span. Calls after the span has ended are ignored. - recordException(exception: SpanException): void; + recordException(exception: Exception): void; // Ends the span and submits its attributes to the tracing system. Idempotent. end(): void; @@ -88,4 +94,4 @@ export default tracing; // Re-export `Span` as a named type export for callers that prefer `import type { Span }` // over `InstanceType`. The runtime module does not have a named // `Span` export - this is purely a type-level convenience. -export type { Span }; +export type { Exception, Span }; diff --git a/src/workerd/api/tracing.c++ b/src/workerd/api/tracing.c++ index 78710e5a4a9..03dd9d0805f 100644 --- a/src/workerd/api/tracing.c++ +++ b/src/workerd/api/tracing.c++ @@ -105,12 +105,25 @@ void SpanImpl::setAttribute(kj::String key, kj::Maybe maybeValue) { // If value is kj::none the attribute is left unset (undefined on the JS side). } -void SpanImpl::recordException(kj::String name, kj::String message, kj::Maybe stack) { +void SpanImpl::recordException(kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack) { if (!builder.isObserved()) { return; } size_t valueSize = name.size() + message.size(); + KJ_IF_SOME(c, code) { + KJ_SWITCH_ONEOF(c) { + KJ_CASE_ONEOF(text, kj::String) { + valueSize += text.size(); + } + KJ_CASE_ONEOF(_, double) { + valueSize += sizeof(double); + } + } + } KJ_IF_SOME(s, stack) { valueSize += s.size(); } @@ -119,7 +132,7 @@ void SpanImpl::recordException(kj::String name, kj::String message, kj::Maybe stack; + kj::Maybe code; auto handle = exception.getHandle(js); if (handle->IsString()) { message = jsg::JsValue(handle).toString(js); } else if (handle->IsObject()) { auto data = KJ_REQUIRE_NONNULL(exceptionHandler.tryUnwrap(js, handle)); - KJ_IF_SOME(code, data.code) { - KJ_SWITCH_ONEOF(code) { + bool hasRequiredField = + data.code != kj::none || data.name != kj::none || data.message != kj::none; + if (!hasRequiredField) { + return; + } + KJ_IF_SOME(c, data.code) { + KJ_SWITCH_ONEOF(c) { KJ_CASE_ONEOF(s, kj::String) { - if (s.size() > 0) { - name = kj::mv(s); - } + code = kj::mv(s); } KJ_CASE_ONEOF(n, double) { - if (n != 0 && n == n) { - name = kj::str(n); - } + code = n; } } } - if (name.size() == 0) { - KJ_IF_SOME(n, data.name) { - name = kj::mv(n); - } + KJ_IF_SOME(n, data.name) { + name = kj::mv(n); } KJ_IF_SOME(m, data.message) { message = kj::mv(m); @@ -218,20 +231,16 @@ void Span::recordException( KJ_IF_SOME(s, data.stack) { stack = kj::mv(s); } - - if (name.size() == 0 && message.size() == 0) { - return; - } } else { return; } KJ_SWITCH_ONEOF(impl) { KJ_CASE_ONEOF(s, kj::Own) { - s->recordException(kj::mv(name), kj::mv(message), kj::mv(stack)); + s->recordException(kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack)); } KJ_CASE_ONEOF(s, IoOwn) { - s->recordException(kj::mv(name), kj::mv(message), kj::mv(stack)); + s->recordException(kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack)); } } } diff --git a/src/workerd/api/tracing.h b/src/workerd/api/tracing.h index 7f2c8b87711..2ccadfeb9e3 100644 --- a/src/workerd/api/tracing.h +++ b/src/workerd/api/tracing.h @@ -29,6 +29,8 @@ constexpr size_t MAX_USER_OPERATION_NAME_BYTES = 64; using TagValue = kj::OneOf; struct ExceptionData { + // JSG dictionaries cannot express "at least one field is required". recordException() + // validates the OpenTelemetry Exception union after conversion. jsg::Optional> code; jsg::Optional name; jsg::Optional message; @@ -67,7 +69,10 @@ class SpanImpl final: public kj::Refcounted { // Sets a single attribute on the span. If value is kj::none, the attribute is not set. void setAttribute(kj::String key, kj::Maybe maybeValue); - void recordException(kj::String name, kj::String message, kj::Maybe stack); + void recordException(kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack); private: workerd::SpanBuilder builder; diff --git a/src/workerd/io/trace-stream.c++ b/src/workerd/io/trace-stream.c++ index 4f63b4d99ec..620d8dfa04b 100644 --- a/src/workerd/io/trace-stream.c++ +++ b/src/workerd/io/trace-stream.c++ @@ -494,6 +494,16 @@ jsg::JsValue ToJs(jsg::Lock& js, const DiagnosticChannelEvent& dce, StringCache& jsg::JsValue ToJs(jsg::Lock& js, const Exception& ex, StringCache& cache) { auto obj = js.obj(); obj.set(js, TYPE_STR, cache.get(js, EXCEPTION_STR)); + KJ_IF_SOME(code, ex.code) { + KJ_SWITCH_ONEOF(code) { + KJ_CASE_ONEOF(text, kj::String) { + obj.set(js, CODE_STR, js.str(text)); + } + KJ_CASE_ONEOF(number, double) { + obj.set(js, CODE_STR, js.num(number)); + } + } + } obj.set(js, NAME_STR, cache.get(js, ex.name)); obj.set(js, MESSAGE_STR, js.str(ex.message)); KJ_IF_SOME(stack, ex.stack) { diff --git a/src/workerd/io/trace-test.c++ b/src/workerd/io/trace-test.c++ index c392d6a9efe..2c30c5f9cc7 100644 --- a/src/workerd/io/trace-test.c++ +++ b/src/workerd/io/trace-test.c++ @@ -471,12 +471,29 @@ KJ_TEST("Read/Write Exception works") { KJ_ASSERT(info2.name == "foo"_kj); KJ_ASSERT(info2.message == "bar"_kj); KJ_ASSERT(info2.stack == kj::none); + KJ_ASSERT(info2.code == kj::none); Exception info3 = info.clone(); KJ_ASSERT(info.timestamp == info3.timestamp); KJ_ASSERT(info3.name == "foo"_kj); KJ_ASSERT(info3.message == "bar"_kj); KJ_ASSERT(info3.stack == kj::none); + KJ_ASSERT(info3.code == kj::none); + + capnp::MallocMessageBuilder stringCodeBuilder; + auto stringCodeRoot = stringCodeBuilder.initRoot(); + kj::Maybe stringCode = Exception::Code(kj::str("ERR_TEST")); + Exception stringCodeInfo( + kj::UNIX_EPOCH, kj::str("foo"), kj::str("bar"), kj::none, kj::mv(stringCode)); + stringCodeInfo.copyTo(stringCodeRoot); + Exception stringCodeRoundTrip(stringCodeRoot.asReader()); + KJ_ASSERT(KJ_ASSERT_NONNULL(stringCodeRoundTrip.code).get() == "ERR_TEST"_kj); + + kj::Maybe numericCode = Exception::Code(42.0); + Exception numericCodeInfo( + kj::UNIX_EPOCH, kj::str("foo"), kj::str("bar"), kj::none, kj::mv(numericCode)); + Exception numericCodeClone = numericCodeInfo.clone(); + KJ_ASSERT(KJ_ASSERT_NONNULL(numericCodeClone.code).get() == 42.0); } KJ_TEST("Read/Write StreamDiagnosticsEvent works") { diff --git a/src/workerd/io/trace.c++ b/src/workerd/io/trace.c++ index be887080e1a..b66540c6053 100644 --- a/src/workerd/io/trace.c++ +++ b/src/workerd/io/trace.c++ @@ -862,12 +862,16 @@ Log Log::clone() const { timestamp, logLevel, kj::str(message), cloneLogErrorInfo(errorInfo), LogTruncated(truncated)); } -Exception::Exception( - kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack) +Exception::Exception(kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack, + kj::Maybe code) : timestamp(timestamp), name(kj::mv(name)), message(kj::mv(message)), - stack(kj::mv(stack)) {} + stack(kj::mv(stack)), + code(kj::mv(code)) {} Log::Log(rpc::Trace::Log::Reader reader) : timestamp(kj::UNIX_EPOCH + reader.getTimestampNs() * kj::NANOSECONDS), @@ -899,6 +903,17 @@ Exception::Exception(rpc::Trace::Exception::Reader reader) if (reader.hasStack()) { stack = kj::str(reader.getStack()); } + auto code = reader.getCode(); + switch (code.which()) { + case rpc::Trace::Exception::Code::NONE: + break; + case rpc::Trace::Exception::Code::TEXT: + this->code = kj::str(code.getText()); + break; + case rpc::Trace::Exception::Code::NUMBER: + this->code = code.getNumber(); + break; + } } void Exception::copyTo(rpc::Trace::Exception::Builder builder) const { @@ -908,10 +923,32 @@ void Exception::copyTo(rpc::Trace::Exception::Builder builder) const { KJ_IF_SOME(s, stack) { builder.setStack(s); } + KJ_IF_SOME(c, code) { + KJ_SWITCH_ONEOF(c) { + KJ_CASE_ONEOF(text, kj::String) { + builder.initCode().setText(text); + } + KJ_CASE_ONEOF(number, double) { + builder.initCode().setNumber(number); + } + } + } } Exception Exception::clone() const { - return Exception(timestamp, kj::str(name), kj::str(message), mapCopyString(stack)); + kj::Maybe clonedCode; + KJ_IF_SOME(c, code) { + KJ_SWITCH_ONEOF(c) { + KJ_CASE_ONEOF(text, kj::String) { + clonedCode = kj::str(text); + } + KJ_CASE_ONEOF(number, double) { + clonedCode = number; + } + } + } + return Exception( + timestamp, kj::str(name), kj::str(message), mapCopyString(stack), kj::mv(clonedCode)); } } // namespace tracing @@ -1924,13 +1961,15 @@ void SpanBuilder::addLog(kj::Date timestamp, kj::ConstString key, TagValue value } } -void SpanBuilder::recordException( - kj::String name, kj::String message, kj::Maybe stack) { +void SpanBuilder::recordException(kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack) { if (span == kj::none) { return; } KJ_IF_SOME(o, observer) { - o->onException(o->getTime(), kj::mv(name), kj::mv(message), kj::mv(stack)); + o->onException(o->getTime(), kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack)); } } diff --git a/src/workerd/io/trace.h b/src/workerd/io/trace.h index d8e62f5289f..4811d8ca592 100644 --- a/src/workerd/io/trace.h +++ b/src/workerd/io/trace.h @@ -683,8 +683,13 @@ struct Log final { // Describes an exception event struct Exception final { - explicit Exception( - kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack); + using Code = kj::OneOf; + + explicit Exception(kj::Date timestamp, + kj::String name, + kj::String message, + kj::Maybe stack, + kj::Maybe code = kj::none); Exception(rpc::Trace::Exception::Reader reader); Exception(Exception&&) noexcept = default; KJ_DISALLOW_COPY(Exception); @@ -697,6 +702,7 @@ struct Exception final { kj::String message; kj::Maybe stack; + kj::Maybe code; void copyTo(rpc::Trace::Exception::Builder builder) const; Exception clone() const; @@ -1255,7 +1261,10 @@ class SpanBuilder { void addLog(kj::Date timestamp, kj::ConstString key, TagValue value); // Records an exception associated with this span. Calls after end() are ignored. - void recordException(kj::String name, kj::String message, kj::Maybe stack); + void recordException(kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack); private: kj::Maybe> observer; @@ -1295,8 +1304,11 @@ class SpanObserver: public kj::Refcounted { // the observer takes ownership. virtual void onClose(kj::Date endTime, Span::TagMap&& tags, kj::Vector&& logs) = 0; - virtual void onException( - kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack) {} + virtual void onException(kj::Date timestamp, + kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack) {} // Called when the operation name is changed after the span was opened (via // SpanBuilder::setOperationName()). Observers that eagerly stream the open event should handle diff --git a/src/workerd/io/tracer.c++ b/src/workerd/io/tracer.c++ index 58a121414af..736b17a287d 100644 --- a/src/workerd/io/tracer.c++ +++ b/src/workerd/io/tracer.c++ @@ -41,23 +41,38 @@ tracing::Attribute::Value cloneAttributeValue(const tracing::Attribute::Value& v void reportExceptionToTailStream(tracing::TailStreamWriter& writer, const tracing::InvocationSpanContext& context, kj::Date timestamp, + kj::Maybe code, kj::StringPtr name, kj::StringPtr message, kj::Maybe stack) { - auto truncatedName = name.first(kj::min(name.size(), MAX_TRACE_BYTES)); + kj::Maybe truncatedCode; + size_t codeSize = 0; + KJ_IF_SOME(c, code) { + KJ_SWITCH_ONEOF(c) { + KJ_CASE_ONEOF(text, kj::String) { + codeSize = kj::min(text.size(), MAX_TRACE_BYTES); + truncatedCode = kj::str(text.first(codeSize)); + } + KJ_CASE_ONEOF(number, double) { + codeSize = sizeof(double); + truncatedCode = number; + } + } + } + auto truncatedName = name.first(kj::min(name.size(), MAX_TRACE_BYTES - codeSize)); auto truncatedMessage = - message.first(kj::min(message.size(), MAX_TRACE_BYTES - truncatedName.size())); + message.first(kj::min(message.size(), MAX_TRACE_BYTES - codeSize - truncatedName.size())); kj::Maybe truncatedStack; size_t truncatedStackSize = 0; KJ_IF_SOME(s, stack) { - truncatedStackSize = - kj::min(s.size(), MAX_TRACE_BYTES - truncatedName.size() - truncatedMessage.size()); + truncatedStackSize = kj::min( + s.size(), MAX_TRACE_BYTES - codeSize - truncatedName.size() - truncatedMessage.size()); truncatedStack = kj::heapString(s.first(truncatedStackSize)); } writer.report(context, - {tracing::Exception( - timestamp, kj::str(truncatedName), kj::str(truncatedMessage), kj::mv(truncatedStack))}, - timestamp, truncatedName.size() + truncatedMessage.size() + truncatedStackSize); + {tracing::Exception(timestamp, kj::str(truncatedName), kj::str(truncatedMessage), + kj::mv(truncatedStack), kj::mv(truncatedCode))}, + timestamp, codeSize + truncatedName.size() + truncatedMessage.size() + truncatedStackSize); } } // namespace @@ -281,7 +296,7 @@ void WorkerTracer::addException(const tracing::InvocationSpanContext& context, KJ_IF_SOME(s, stack) { stackPtr = s; } - reportExceptionToTailStream(*writer, context, timestamp, name, message, stackPtr); + reportExceptionToTailStream(*writer, context, timestamp, kj::none, name, message, stackPtr); } if (trace->exceededExceptionLimit) { @@ -301,6 +316,7 @@ void WorkerTracer::addException(const tracing::InvocationSpanContext& context, void WorkerTracer::addSpanException(tracing::SpanId spanId, kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) { @@ -317,7 +333,7 @@ void WorkerTracer::addSpanException(tracing::SpanId spanId, KJ_IF_SOME(s, stack) { stackPtr = s; } - reportExceptionToTailStream(*writer, context, timestamp, name, message, stackPtr); + reportExceptionToTailStream(*writer, context, timestamp, kj::mv(code), name, message, stackPtr); } void WorkerTracer::addDiagnosticChannelEvent(const tracing::InvocationSpanContext& context, @@ -654,10 +670,14 @@ void UserSpanObserver::onOpen(kj::ConstString operationName, kj::Date startTime) } } -void UserSpanObserver::onException( - kj::Date timestamp, kj::String name, kj::String message, kj::Maybe stack) { +void UserSpanObserver::onException(kj::Date timestamp, + kj::Maybe code, + kj::String name, + kj::String message, + kj::Maybe stack) { if (wasAccepted) { - submitter->submitSpanException(spanId, timestamp, kj::mv(name), kj::mv(message), kj::mv(stack)); + submitter->submitSpanException( + spanId, timestamp, kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack)); } } diff --git a/src/workerd/io/tracer.h b/src/workerd/io/tracer.h index 3a6da17118b..a5c61fb85d8 100644 --- a/src/workerd/io/tracer.h +++ b/src/workerd/io/tracer.h @@ -65,6 +65,7 @@ class BaseTracer: public kj::Refcounted { // Records an exception event on a span without treating the invocation as having thrown. virtual void addSpanException(tracing::SpanId spanId, kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) = 0; @@ -174,6 +175,7 @@ class WorkerTracer final: public BaseTracer { kj::Maybe stack) override; void addSpanException(tracing::SpanId spanId, kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) override; @@ -249,6 +251,7 @@ class SpanSubmitter: public kj::Refcounted { virtual void submitSpanException(tracing::SpanId spanId, kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) = 0; @@ -295,6 +298,7 @@ class UserSpanObserver final: public SpanObserver { void onOpen(kj::ConstString operationName, kj::Date startTime) override; void onClose(kj::Date endTime, Span::TagMap&& tags, kj::Vector&& logs) override; void onException(kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) override; diff --git a/src/workerd/io/worker-interface.capnp b/src/workerd/io/worker-interface.capnp index 171f60a7a03..bf9ef0a5f75 100644 --- a/src/workerd/io/worker-interface.capnp +++ b/src/workerd/io/worker-interface.capnp @@ -125,6 +125,11 @@ struct Trace @0x8e8d911203762d34 { name @1 :Text; message @2 :Text; stack @3 :Text; + code :union { + none @4 :Void; + text @5 :Text; + number @6 :Float64; + } } outcome @2 :EventOutcome; diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index db6870d692c..b94a39d8f8e 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -3201,6 +3201,7 @@ class SequentialSpanSubmitter final: public SpanSubmitter { void submitSpanException(tracing::SpanId spanId, kj::Date timestamp, + kj::Maybe code, kj::String name, kj::String message, kj::Maybe stack) override { @@ -3208,7 +3209,8 @@ class SequentialSpanSubmitter final: public SpanSubmitter { if (isPredictableModeForTest()) { timestamp = kj::UNIX_EPOCH; } - tracer.addSpanException(spanId, timestamp, kj::mv(name), kj::mv(message), kj::mv(stack)); + tracer.addSpanException( + spanId, timestamp, kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack)); }); } diff --git a/types/defines/trace.d.ts b/types/defines/trace.d.ts index 290f585089e..1e6671ae446 100644 --- a/types/defines/trace.d.ts +++ b/types/defines/trace.d.ts @@ -142,6 +142,7 @@ interface DiagnosticChannelEvent { interface Exception { readonly type: "exception"; + readonly code?: string | number; readonly name: string; readonly message: string; readonly stack?: string; From 19b6c60b233aacf3b26cede96092592ad7d705fe Mon Sep 17 00:00:00 2001 From: Jeremy Morrell Date: Wed, 19 Aug 2026 04:07:03 +0000 Subject: [PATCH 3/3] Format tracing exception type --- src/cloudflare/internal/tracing.d.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cloudflare/internal/tracing.d.ts b/src/cloudflare/internal/tracing.d.ts index d1e659c5c57..5852dffb549 100644 --- a/src/cloudflare/internal/tracing.d.ts +++ b/src/cloudflare/internal/tracing.d.ts @@ -31,10 +31,7 @@ interface ExceptionWithName { } type Exception = - | ExceptionWithCode - | ExceptionWithMessage - | ExceptionWithName - | string; + ExceptionWithCode | ExceptionWithMessage | ExceptionWithName | string; declare class Span { // Returns true if this span will be recorded to the tracing system. False when the