Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3da925f
Add OTEL doc
GeorgeNgMsft Aug 4, 2026
1669002
Add OTEl design doc
GeorgeNgMsft Aug 4, 2026
b9fa740
Add telemetry documentation
GeorgeNgMsft Aug 4, 2026
a6f39a0
Merge branch 'main' into dev/georgeng/otel
GeorgeNgMsft Aug 4, 2026
6a1124d
style: apply prettier formatting and policy fixes
typeagent-bot[bot] Aug 4, 2026
0f711af
Update doc
GeorgeNgMsft Aug 4, 2026
91899b5
Resolve merge conflict
GeorgeNgMsft Aug 5, 2026
f5d92a2
Readability pass
GeorgeNgMsft Aug 5, 2026
0e9be1f
Implement OpenTelemetry Phase 0 foundation
GeorgeNgMsft Aug 5, 2026
1563430
Update shutdown timeline to include overall deadline and generate ser…
GeorgeNgMsft Aug 5, 2026
f49e4c8
Add bootstrap and lifecycle management logic
GeorgeNgMsft Aug 5, 2026
d23b193
Add OTel standard metadata for TypeAgent hosts
GeorgeNgMsft Aug 6, 2026
3d2dcd4
Merge origin/main into dev/georgeng/otel-resources
GeorgeNgMsft Aug 6, 2026
35395be
Wire telemetry lifecycle into owned hosts
GeorgeNgMsft Aug 6, 2026
afbdb02
Coordinate telemetry shutdown for process signals
GeorgeNgMsft Aug 6, 2026
02c915c
Complete OpenTelemetry foundation test coverage
GeorgeNgMsft Aug 6, 2026
eff455c
Merge origin/main into dev/georgeng/otel-initialize-hosts
GeorgeNgMsft Aug 7, 2026
31fbaed
Fix lockfile after main merge
GeorgeNgMsft Aug 7, 2026
15b1cf8
Merge branch 'main' into dev/georgeng/otel-initialize-hosts
GeorgeNgMsft Aug 7, 2026
025487e
Improve graceful shutdown and timeout for cleanup
GeorgeNgMsft Aug 7, 2026
e7a1907
Avoid usage of global state
GeorgeNgMsft Aug 7, 2026
5363e9f
Improve websocket cleanup if shutdown early
GeorgeNgMsft Aug 7, 2026
93fa55d
Merge branch 'main' into dev/georgeng/otel-initialize-hosts
GeorgeNgMsft Aug 7, 2026
92cf1b8
Merge remote-tracking branch 'origin/main' into george/otel-phase1-1-…
GeorgeNgMsft Aug 8, 2026
1ffbc46
Define typeagent span
GeorgeNgMsft Aug 8, 2026
310485c
Resolve merge conflict
GeorgeNgMsft Aug 8, 2026
d57d3e5
Merge branch 'dev/georgeng/otel-initialize-hosts' into george/otel-ph…
GeorgeNgMsft Aug 8, 2026
e2cb071
Fix lock file
GeorgeNgMsft Aug 8, 2026
cef75bf
Rename inMemorySpanHarness to inMemorySpanManager
GeorgeNgMsft Aug 8, 2026
21aaa78
Merge branch 'main' into george/otel-phase1-2-root-request-span
GeorgeNgMsft Aug 9, 2026
c866c2d
Add OTel root request spans
GeorgeNgMsft Aug 10, 2026
7ac3e80
Merge branch 'main' into george/otel-phase1-2-root-request-span
GeorgeNgMsft Aug 10, 2026
8470683
Address comments
GeorgeNgMsft Aug 10, 2026
17e0aa0
Remove unnecessary directive
GeorgeNgMsft Aug 10, 2026
4dabba4
Merge branch 'main' into george/otel-phase1-2-root-request-span
GeorgeNgMsft Aug 10, 2026
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
23 changes: 20 additions & 3 deletions ts/docs/architecture/telemetry/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ Ordinary `debug(...)` and `logger.logEvent(...)` calls do not change. Add a span
for an externally meaningful or independently timed operation:

```ts
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { trace } from "@opentelemetry/api";
import { otel } from "@typeagent/telemetry";

const tracer = trace.getTracer("typeagent");

Expand All @@ -344,8 +345,10 @@ return tracer.startActiveSpan("typeagent.translate", async (span) => {
span.setAttribute("typeagent.agent.name", agentName);
return await translateRequest(request);
} catch (error) {
span.recordException(error instanceof Error ? error : String(error));
span.setStatus({ code: SpanStatusCode.ERROR });
otel.recordTypeAgentSpanException(span, error, {
safeName: "TranslationError",
safeMessage: "translation failed",
});
throw error;
} finally {
span.end();
Expand All @@ -357,6 +360,20 @@ return tracer.startActiveSpan("typeagent.translate", async (span) => {
inside the callback receive its trace and span IDs. If code converts an exception
to `ActionResult`, it must still record the exception and error status.

Dispatcher request spans start a new trace by default. Embedded hosts can join
the OTel context active when each request is submitted with a one-time option:

```ts
await createDispatcher(hostName, {
telemetry: { joinActiveTrace: true },
});
```

Original exception messages and stacks are omitted because they can contain user
content. A host may explicitly opt into redacted details with
`telemetry.captureSensitiveErrorDetails`, but this remains sensitive diagnostic
capture even after known secrets are removed.

| Signal | Use |
| --------------- | -------------------------------------------- |
| Span attributes | Stable facts such as agent, action, or model |
Expand Down
1 change: 1 addition & 0 deletions ts/packages/dispatcher/dispatcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@azure/identity": "^4.10.0",
"@github/copilot-sdk": "1.0.5",
"@modelcontextprotocol/sdk": "^1.26.0",
"@opentelemetry/api": "1.9.0",
"@typeagent/action-grammar": "workspace:*",
"@typeagent/action-schema": "workspace:*",
"@typeagent/agent-cache": "workspace:*",
Expand Down
147 changes: 118 additions & 29 deletions ts/packages/dispatcher/dispatcher/src/command/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import {
getRequestId,
requestIdToString,
} from "../context/commandHandlerContext.js";
import {
context as otelContext,
trace,
type Context,
} from "@opentelemetry/api";
import { otel } from "@typeagent/telemetry";
import { wrapRootRequestSpan } from "../otel/rootRequestSpan.js";
import { getSessionName } from "../context/session.js";

import {
CommandDescriptor,
Expand Down Expand Up @@ -356,6 +364,23 @@ export async function processCommandNoLock(
if (e.name === "AbortError" || context.currentAbortSignal?.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
// Record the exception on the active `typeagent.request` span
// *before* processCommandNoLock swallows it. The design doc rule
// (opentelemetry.md, "Developer Usage") is explicit: "If code
// converts an exception to `ActionResult`, it must still record
// the exception and error status." The catch below converts the
// thrown error into a user-visible display message + logged event,
// not a re-throw; without this recordException/setStatus pair the
// root span would silently end with status UNSET on real failures.
const activeSpan = trace.getActiveSpan();
if (activeSpan !== undefined) {
otel.recordTypeAgentSpanException(activeSpan, e, {
safeName: "CommandError",
safeMessage: "command failed",
Comment thread
GeorgeNgMsft marked this conversation as resolved.
Outdated
captureSensitiveDetails:
context.telemetryOptions.captureSensitiveErrorDetails,
});
}
context.clientIO.appendDisplay(
makeClientIOMessage(
context,
Expand Down Expand Up @@ -442,6 +467,7 @@ export async function processCommand(
requestId: RequestId,
attachments?: string[],
options?: ProcessCommandOptions,
parentContext?: Context,
): Promise<CommandResult | undefined> {
// Create the AbortController *before* acquiring the lock so that a
// cancelCommandByClientId() call that arrives while we are queued can
Expand All @@ -453,38 +479,101 @@ export async function processCommand(
abortController,
);
}
try {
// Process one command at a time.
return await context.commandLock(async () => {
const requestIdStr = requestId.requestId;
context.activeRequests.set(requestIdStr, abortController);
context.currentOptions = options;
beginProcessCommand(
requestId,
context,
options,
abortController.signal,
);
context.clientIO.setUserRequest(requestId, originalInput);
// Compute the correlation attributes ONCE, before opening the root
// `typeagent.request` span. Values that only become known later
// (agent name, action name) are set on the active span by downstream
// steps in later phases; the root span carries only the values known
// at the outermost async boundary. Everything the wrapper receives is
// an identifier, not user text - see setTypeAgentSpanAttributes.
const sessionId = context.session.sessionDirPath
Comment thread
GeorgeNgMsft marked this conversation as resolved.
? getSessionName(context.session.sessionDirPath)
: undefined;
const rootAttributes: {
-readonly [K in keyof otel.TypeAgentSpanAttributes]: otel.TypeAgentSpanAttributes[K];
} = {};
if (sessionId !== undefined) rootAttributes.sessionId = sessionId;
if (context.activationId !== undefined)
rootAttributes.activationId = context.activationId;
if (context.traceId !== undefined) rootAttributes.traceId = context.traceId;
// wrapRootRequestSpan opens `typeagent.request` and applies the
// correlation attributes; the callback body preserves the original
// command flow. startActiveSpan uses AsyncHooks context propagation
// so every downstream await (commandLock, processCommandNoLock,
// translation/reasoning/action) automatically nests under this span.
const rootParentContext =
parentContext ??
(context.telemetryOptions.joinActiveTrace
? otelContext.active()
: undefined);
return await wrapRootRequestSpan(
rootAttributes,
async () => {
try {
await processCommandNoLock(originalInput, context, attachments);
} catch (e: any) {
if (e.name === "AbortError") {
ensureCommandResult(context).cancelled = true;
} else {
throw e;
}
// Process one command at a time.
return await context.commandLock(async () => {
const requestIdStr = requestId.requestId;
context.activeRequests.set(requestIdStr, abortController);
context.currentOptions = options;
beginProcessCommand(
requestId,
context,
options,
abortController.signal,
);
context.clientIO.setUserRequest(requestId, originalInput);
try {
await processCommandNoLock(
originalInput,
context,
attachments,
);
} catch (e: any) {
if (e.name === "AbortError") {
// Design rule: exceptions converted to ActionResult
// must still be recorded on the active span. Do it
// here (inside the wrapper's context) so the
// exception event lands on `typeagent.request`.
// The wrapper separately sets ERROR status with
// message "cancelled" when it sees cancelled=true.
const activeSpan = trace.getActiveSpan();
if (activeSpan !== undefined) {
otel.recordTypeAgentSpanException(
activeSpan,
e,
{
safeName: "AbortError",
safeMessage: "cancelled",
captureSensitiveDetails:
context.telemetryOptions
.captureSensitiveErrorDetails,
},
);
}
ensureCommandResult(context).cancelled = true;
} else {
throw e;
}
} finally {
context.activeRequests.delete(requestIdStr);
context.currentOptions = undefined;
// eslint-disable-next-line no-unsafe-finally
return endProcessCommand(requestId, context);
}
});
} finally {
context.activeRequests.delete(requestIdStr);
context.currentOptions = undefined;
return endProcessCommand(requestId, context);
if (requestId.clientRequestId !== undefined) {
context.activeRequestsByClientId.delete(
requestId.clientRequestId,
);
}
}
});
} finally {
if (requestId.clientRequestId !== undefined) {
context.activeRequestsByClientId.delete(requestId.clientRequestId);
}
}
},
{
parentContext: rootParentContext,
captureSensitiveErrorDetails:
context.telemetryOptions.captureSensitiveErrorDetails,
},
);
}

export const enum unicodeChar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,22 @@ export type CommandHandlerContext = {
persistedGrammarStore?: PersistedGrammarStore; // Persistence layer for dynamic grammar rules
currentScriptDir: string;
logger?: Logger | undefined;
/**
* Dispatcher activation id (created once per context, not per request).
* Mirrors what the logger stamps on events and is the correlation value
* used for the `typeagent.activation.id` OTel span attribute.
*/
readonly activationId: string;
/**
* Preserved caller-supplied pre-OTel trace id. OTel owns the canonical
* trace id; this value is carried on spans and logs as
* `typeagent.trace.id` so existing logs can still be joined.
*/
readonly traceId: string | undefined;
readonly telemetryOptions: {
readonly joinActiveTrace: boolean;
readonly captureSensitiveErrorDetails: boolean;
};
currentRequestId: RequestId | undefined;
currentAbortSignal: AbortSignal | undefined;
currentOptions?: ProcessCommandOptions | undefined;
Expand Down Expand Up @@ -534,6 +550,7 @@ async function getAgentCache(
* - collectCommandResult: whether to collect command result in the return for `processCommand`. Default is false.
* - dblogging: whether to enable database telemetry logging. Default is true; pass false to opt out.
* - traceId: An optional trace ID to use for logging identification.
* - telemetry: OpenTelemetry request-span integration options.
*/
export type DispatcherOptions = DeepPartialUndefined<DispatcherConfig> & {
// Core options
Expand Down Expand Up @@ -577,6 +594,18 @@ export type DispatcherOptions = DeepPartialUndefined<DispatcherConfig> & {
collectCommandResult?: boolean; // default to false
dblogging?: boolean; // default to true
traceId?: string; // optional additional for logging identification
telemetry?: {
/**
* Join the OTel context active when each request is submitted.
* Default false: each request starts a new trace.
*/
joinActiveTrace?: boolean;
/**
* Export redacted original exception messages and stacks. These can
* still contain user content, so this is disabled by default.
*/
captureSensitiveErrorDetails?: boolean;
};

// Additional integration options
constructionProvider?: ConstructionProvider;
Expand Down Expand Up @@ -1184,14 +1213,16 @@ export async function initializeCommandHandlerContext(
debug(`Session directory: ${sessionDirPath}`);
const clientIO = options?.clientIO ?? nullClientIO;
const loggerSink = getLoggerSink(() => context.dblogging, clientIO);
const activationId = randomUUID();
const traceId = options?.traceId;
const logger = new ChildLogger(loggerSink, DispatcherName, {
hostName,
traceId: options?.traceId,
traceId,
sessionId: () =>
context.session.sessionDirPath
? getSessionName(context.session.sessionDirPath)
: undefined,
activationId: randomUUID(),
activationId,
});

const cacheDir = persistDir ? ensureCacheDir(persistDir) : undefined;
Expand Down Expand Up @@ -1276,6 +1307,13 @@ export async function initializeCommandHandlerContext(
),
displayLog: await DisplayLog.load(persistDir),
logger,
activationId,
traceId,
telemetryOptions: {
joinActiveTrace: options?.telemetry?.joinActiveTrace ?? false,
captureSensitiveErrorDetails:
options?.telemetry?.captureSensitiveErrorDetails ?? false,
},
metricsManager: metrics ? new RequestMetricsManager() : undefined,
promptLogger,
devTrace,
Expand Down Expand Up @@ -1344,6 +1382,7 @@ export async function initializeCommandHandlerContext(
reqId,
qctx.attachments,
qctx.options,
qctx.traceContext,
);
try {
context.displayLog.logCommandResult(
Expand Down
4 changes: 4 additions & 0 deletions ts/packages/dispatcher/dispatcher/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
initializeCommandHandlerContext,
} from "./context/commandHandlerContext.js";
import { randomUUID } from "node:crypto";
import { context as otelContext } from "@opentelemetry/api";
import { getAgentSchemas } from "./context/system/describe/agentSchemaInfo.js";

async function getDynamicDisplay(
Expand Down Expand Up @@ -216,6 +217,9 @@ export function createDispatcherFromContext(
if (attachments != null) input.attachments = attachments;
if (options != null) input.options = options;
if (requestId !== undefined) input.requestId = requestId;
if (context.telemetryOptions.joinActiveTrace) {
input.traceContext = otelContext.active();
}
return input;
};

Expand Down
Loading
Loading