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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -166,6 +167,26 @@ 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];
}

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.
Expand Down Expand Up @@ -285,6 +306,57 @@ 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.code, 42);
assert.strictEqual(coded.name, '');
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.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.
{
const { inv } = findInvocationBySpanName('record-exception-ended');
assert.strictEqual(inv.exceptions.length, 0);
}

console.log('All tracing-log-attribution tests passed!');
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,33 @@ 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.recordException({ code: 'CODE_ONLY' });
detached.recordException({ stack: 'ignored-stack-only' });
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');
},
};
29 changes: 28 additions & 1 deletion src/cloudflare/internal/tracing.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ interface SpanAttributes {
[key: string]: SpanValue | undefined;
}

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
// current async context is not being traced, or when the span has already been submitted.
Expand All @@ -21,6 +45,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: Exception): void;

// Ends the span and submits its attributes to the tracing system. Idempotent.
end(): void;
}
Expand Down Expand Up @@ -64,4 +91,4 @@ export default tracing;
// Re-export `Span` as a named type export for callers that prefer `import type { Span }`
// over `InstanceType<typeof tracing.Span>`. The runtime module does not have a named
// `Span` export - this is purely a type-level convenience.
export type { Span };
export type { Exception, Span };
83 changes: 83 additions & 0 deletions src/workerd/api/tracing.c++
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,36 @@ void SpanImpl::setAttribute(kj::String key, kj::Maybe<TagValue> maybeValue) {
// If value is kj::none the attribute is left unset (undefined on the JS side).
}

void SpanImpl::recordException(kj::Maybe<tracing::Exception::Code> code,
kj::String name,
kj::String message,
kj::Maybe<kj::String> 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();
}
bytesUsed += valueSize;
if (bytesUsed > MAX_SPAN_BYTES) {
setSpanDataLimitError("exception", name, valueSize);
return;
}
builder.recordException(kj::mv(code), 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;
Expand Down Expand Up @@ -162,6 +192,59 @@ jsg::Ref<Span> Span::setAttributes(jsg::Lock& js, jsg::Dict<jsg::Optional<TagVal
return JSG_THIS;
}

void Span::recordException(
jsg::Lock& js, jsg::Value exception, const jsg::TypeHandler<ExceptionData>& exceptionHandler) {
if (!getIsTraced()) {
return;
}

kj::String name;
kj::String message;
kj::Maybe<kj::String> stack;
kj::Maybe<tracing::Exception::Code> 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));
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) {
code = kj::mv(s);
}
KJ_CASE_ONEOF(n, double) {
code = n;
}
}
}
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);
}
} else {
return;
}

KJ_SWITCH_ONEOF(impl) {
KJ_CASE_ONEOF(s, kj::Own<SpanImpl>) {
s->recordException(kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack));
}
KJ_CASE_ONEOF(s, IoOwn<SpanImpl>) {
s->recordException(kj::mv(code), kj::mv(name), kj::mv(message), kj::mv(stack));
}
}
}

void Span::end() {
KJ_SWITCH_ONEOF(impl) {
KJ_CASE_ONEOF(s, kj::Own<SpanImpl>) {
Expand Down
27 changes: 26 additions & 1 deletion src/workerd/api/tracing.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ constexpr size_t MAX_USER_OPERATION_NAME_BYTES = 64;
// The types allowed for tag and log values from JavaScript.
using TagValue = kj::OneOf<bool, double, kj::String>;

struct ExceptionData {
// JSG dictionaries cannot express "at least one field is required". recordException()
// validates the OpenTelemetry Exception union after conversion.
jsg::Optional<kj::OneOf<kj::String, double>> code;
jsg::Optional<kj::String> name;
jsg::Optional<kj::String> message;
jsg::Optional<kj::String> 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.
Expand Down Expand Up @@ -58,6 +69,11 @@ 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<TagValue> maybeValue);

void recordException(kj::Maybe<tracing::Exception::Code> code,
kj::String name,
kj::String message,
kj::Maybe<kj::String> stack);

private:
workerd::SpanBuilder builder;

Expand Down Expand Up @@ -90,6 +106,9 @@ class Span: public jsg::Object {
// Sets each attribute in `attributes` as if by calling setAttribute().
jsg::Ref<Span> setAttributes(jsg::Lock& js, jsg::Dict<jsg::Optional<TagValue>> attributes);

void recordException(
jsg::Lock& js, jsg::Value exception, const jsg::TypeHandler<ExceptionData>& exceptionHandler);

// Ends the span and submits its content to the tracing system. Idempotent.
void end();

Expand All @@ -98,13 +117,18 @@ class Span: public jsg::Object {

JSG_METHOD(setAttribute);
JSG_METHOD(setAttributes);
JSG_METHOD(recordException);
JSG_METHOD(end);

JSG_TS_OVERRIDE({
setAttribute(key: string, value: boolean | number | string): this;
setAttributes(
attributes: Record<string, boolean | number | string | undefined>
): 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;
});
}

Expand Down Expand Up @@ -212,4 +236,5 @@ kj::Own<jsg::modules::ModuleBundle> 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
10 changes: 10 additions & 0 deletions src/workerd/io/trace-stream.c++
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions src/workerd/io/trace-test.c++
Original file line number Diff line number Diff line change
Expand Up @@ -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<rpc::Trace::Exception>();
kj::Maybe<Exception::Code> 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<kj::String>() == "ERR_TEST"_kj);

kj::Maybe<Exception::Code> 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<double>() == 42.0);
}

KJ_TEST("Read/Write StreamDiagnosticsEvent works") {
Expand Down
Loading
Loading