Skip to content

Commit 5d8cc25

Browse files
committed
Add custom trace spans and heavy-operation duration logs
createTracer (backend-utils/tracing) opens a span and stamps the ambient observability context onto it as attributes — tracing only, no logging. Spans cover the two interior operations auto-instrumentation can't see: agent.run and code.snapshot.rebuild. Chat deletion, workspace deletion, and storage migration get duration logs; their RPC/constructor boundaries are already auto-traced.
1 parent ff5a76b commit 5d8cc25

4 files changed

Lines changed: 84 additions & 14 deletions

File tree

packages/backend-utils/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"types": "./src/observability-context.ts",
1313
"import": "./src/observability-context.ts"
1414
},
15+
"./tracing": {
16+
"types": "./src/tracing.ts",
17+
"import": "./src/tracing.ts"
18+
},
1519
"./error-reporting": {
1620
"types": "./src/error-reporting.ts",
1721
"import": "./src/error-reporting.ts"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { tracing } from "cloudflare:workers";
2+
3+
type Attribute = boolean | number | string;
4+
5+
// The span surface exposed to callbacks. Lifetime is managed by `traced`, so no `end()`.
6+
export interface TraceSpan {
7+
readonly isTraced: boolean;
8+
setAttribute(key: string, value?: Attribute): void;
9+
}
10+
11+
/**
12+
* Creates a span helper that stamps the ambient observability context onto each span as
13+
* attributes. Tracing only: never logs, never modifies context. Exceptions propagate
14+
* unchanged, marked on the span via an `error` attribute (the beta API has no outcome).
15+
*/
16+
export function createTracer(getContext: () => Readonly<Record<string, unknown>>) {
17+
return function traced<Result>(name: string, callback: (span: TraceSpan) => Result): Result {
18+
return tracing.enterSpan(name, (span) => {
19+
if (span.isTraced) {
20+
for (const [key, value] of Object.entries(getContext())) {
21+
if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
22+
span.setAttribute(key, value);
23+
}
24+
}
25+
}
26+
// Boolean marker only: error text is unbounded and possibly sensitive, so it belongs to
27+
// logs/reporting, not trace attributes.
28+
const fail = () => span.setAttribute("error", true);
29+
try {
30+
const result = callback(span);
31+
return result instanceof Promise
32+
? result.catch((err) => { fail(); throw err; }) as Result
33+
: result;
34+
} catch (err) {
35+
fail();
36+
throw err;
37+
}
38+
});
39+
};
40+
}

packages/workshop-backend/src/observability.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createObservabilityContext } from "@gadgets/backend-utils/observability-context";
2+
import { createTracer } from "@gadgets/backend-utils/tracing";
23

34
/** Observability fields emitted by the Workshop backend. */
45
export type WorkshopObservabilityFields = {
@@ -14,6 +15,7 @@ export type WorkshopObservabilityFields = {
1415
failureCount: number;
1516
gadgetId: string;
1617
gatekeeperId: number | string;
18+
logBytes: number;
1719
modelId: string;
1820
observerId: string;
1921
operation: string;
@@ -37,3 +39,6 @@ export const obsContext = createObservabilityContext<WorkshopObservabilityFields
3739
export function createWorkshopLogger(component: string) {
3840
return obsContext.createLogger({ component });
3941
}
42+
43+
/** Runs `callback` in a trace span carrying the ambient observability fields as attributes. */
44+
export const traced = createTracer(obsContext.get);

packages/workshop-backend/src/overseer.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { refreshCachedBalance } from "./ai-gateway-billing/cloudflare/connection
3737
import { SharingManager, SharingCaller, CollaboratorRecord, ShareKeyRecord } from "./sharing";
3838
import { AutoApprovalDrainer } from "./auto-approval";
3939
import { collectSlashCommands, invokeSlashCommand } from "./slash-commands";
40-
import { createWorkshopLogger, obsContext } from "./observability";
40+
import { createWorkshopLogger, obsContext, traced } from "./observability";
4141
import type { ChatGatewayRpcTarget, SubmitExternalMessageResult } from "@gadgets/workshop-shared/external-message-gateway";
4242
import {
4343
assertChatAttachmentSupportedByProvider,
@@ -1376,6 +1376,7 @@ class OverseerImpl implements AgentHooks {
13761376

13771377
// Run the whole migration in one transaction so that a mid-migration error can't leave the
13781378
// workspace half-migrated.
1379+
let startedAt = Date.now();
13791380
this.ctx.storage.transactionSync(() => {
13801381
// Version 0 -> 1: the workspace predates multi-gadget support. If it has any gadget content
13811382
// (code beyond the initial empty snapshot, or named bindings), register that content as the
@@ -1455,6 +1456,10 @@ class OverseerImpl implements AgentHooks {
14551456

14561457
this.storage.version.put(1);
14571458
});
1459+
1460+
this.logger.info("migrated workspace storage", {
1461+
event: "storage.migration.completed", durationMs: Date.now() - startedAt,
1462+
});
14581463
}
14591464

14601465
// Allocate a workpiece ID from the shared counter. (The counter is named `nextGatekeeperId`
@@ -2003,18 +2008,24 @@ class OverseerImpl implements AgentHooks {
20032008
this.#snapshotMetrics.logSize += update.length;
20042009
if (this.#snapshotMetrics.logSize >
20052010
Math.max(this.#snapshotMetrics.snapshotSize, MIN_SNAPSHOT_THRESHOLD)) {
2006-
let {ydoc} = this.buildYDoc("current");
2007-
let snapshotUpdate = Y.encodeStateAsUpdateV2(ydoc);
2008-
this.storage.snapshots.put({
2009-
version,
2010-
timestamp,
2011-
update: snapshotUpdate
2011+
let logBytes = this.#snapshotMetrics.logSize;
2012+
let startedAt = Date.now();
2013+
traced("code.snapshot.rebuild", (span) => {
2014+
let {ydoc} = this.buildYDoc("current");
2015+
let snapshotUpdate = Y.encodeStateAsUpdateV2(ydoc);
2016+
this.storage.snapshots.put({version, timestamp, update: snapshotUpdate});
2017+
span.setAttribute("gadgetId", this.ctx.id.toString());
2018+
span.setAttribute("size", snapshotUpdate.length);
2019+
span.setAttribute("logBytes", logBytes);
2020+
this.#snapshotMetrics = {
2021+
snapshotSize: snapshotUpdate.length,
2022+
logSize: 0,
2023+
};
2024+
this.logger.info("rebuilt code snapshot", {
2025+
event: "code.snapshot.rebuilt", durationMs: Date.now() - startedAt,
2026+
size: snapshotUpdate.length, logBytes, sequence: version,
2027+
});
20122028
});
2013-
2014-
this.#snapshotMetrics = {
2015-
snapshotSize: snapshotUpdate.length,
2016-
logSize: 0,
2017-
};
20182029
}
20192030
}
20202031

@@ -3857,8 +3868,8 @@ class OverseerImpl implements AgentHooks {
38573868
gadgetId: this.ctx.id.toString(),
38583869
chatId,
38593870
modelId: aiModel.profile.id,
3860-
}, () => this.#runAgentTurnWithContext(
3861-
chatId, aiModel, initiator, callbackInitiated, liveChat));
3871+
}, () => traced("agent.run", () => this.#runAgentTurnWithContext(
3872+
chatId, aiModel, initiator, callbackInitiated, liveChat)));
38623873
}
38633874

38643875
async #runAgentTurnWithContext(chatId: number, aiModel: UserAiModelRecord,
@@ -7311,6 +7322,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
73117322
if (!this.isOwner) {
73127323
throw new Error("Only the workspace owner can delete it.");
73137324
}
7325+
let startedAt = Date.now();
73147326

73157327
this.impl.recordGadgetAnalytics({
73167328
event_name: "gadget_deleted",
@@ -7337,6 +7349,10 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
73377349
this.impl.scheduleRevocationRestart();
73387350
this.impl.ownerId = undefined;
73397351
});
7352+
7353+
this.impl.logger.info("deleted workspace", {
7354+
event: "workspace.delete.completed", durationMs: Date.now() - startedAt,
7355+
});
73407356
}
73417357

73427358
async subscribeToCode(subscriber: RpcStub<CodeSubscriber>, fromVersion: number = 0)
@@ -8390,6 +8406,7 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
83908406
}
83918407

83928408
async deleteChat(chatId: number): Promise<void> {
8409+
let startedAt = Date.now();
83938410
let response = this.impl.storage.gadgetResponseDeliveries.undeliveredByChatId.get(chatId);
83948411
if (response?.status === "waiting") {
83958412
this.impl.deliverExternalMessageResponse(response, "The chat was deleted before the agent responded.");
@@ -8459,6 +8476,10 @@ class OverseerClientInterface extends RpcTarget implements Overseer {
84598476

84608477
// Clean up all in-memory live state for this chat.
84618478
this.impl.destroyLiveChat(chatId);
8479+
8480+
this.impl.logger.info("deleted chat", {
8481+
event: "chat.delete.completed", chatId, durationMs: Date.now() - startedAt,
8482+
});
84628483
}
84638484

84648485
async stopAgent(chatId: number): Promise<void> {

0 commit comments

Comments
 (0)