Skip to content
Open
30 changes: 30 additions & 0 deletions ts/docs/architecture/telemetry/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ restartable or replace host globals.

Each subprocess extracts RPC context, creates child spans with its own provider,
and exports independently. Telemetry payloads do not pass through the dispatcher.
Browser-shared RPC code reads active TypeAgent metadata through the
`@typeagent/telemetry/traceContext` subpath. It does not import the Node-only
telemetry composition root.

The trusted dispatcher RPC channel extends the same trace from a client host
into agent-server request processing:

```text
client rpc.client
-> agent-server rpc.server
-> typeagent.request
-> typeagent.action
-> agent-server rpc.client
-> agent subprocess rpc.server
```

Dispatcher RPC propagation remains opt-in. The agent-server client and server
composition roots enable it for their TypeAgent-owned channel. The shared
agent-server dispatcher also enables `telemetry.joinActiveTrace`, which captures
the RPC SERVER context when a command is submitted and restores it when the
queued `typeagent.request` begins. Embedded dispatcher hosts retain the default
`joinActiveTrace: false`, so installing a provider does not implicitly join
their requests to an unrelated active span.

## Signals

Expand Down Expand Up @@ -289,6 +312,9 @@ OTel owns the canonical trace ID. Preserve the existing caller value as

Send TypeAgent correlation values as explicit, allowlisted RPC metadata, not
broad W3C baggage. V1 does not inject them into generic HTTP propagation.
The process-backed agent boundary also carries bounded `agentName` and
`actionName` values already known at dispatch time. It never adds action
parameters or RPC-internal context identifiers.

Accept remote context only on designated RPC channels. Enforce OTel parsing,
size limits, and correlation-field length and character rules. Ignore malformed
Expand Down Expand Up @@ -840,6 +866,10 @@ Always end manual spans in `finally`.
| **5. Metrics** | **Operational visibility** | Token and duration instruments, bounded attributes, in-memory tests, optional Grafana queries | Readers verify values; partner metrics join the host provider; POC can inspect export |
| **6. Hardening** | **Production-ready v1** | Provider/no-provider, partner wiring, privacy, compatibility, overhead, queue/disk/export/shutdown failures | Partner and operational tests meet ownership, reliability, privacy, and performance requirements |

Phase 3 is complete. A real three-process OTLP smoke test verifies the literal
client, agent-server, request, action, and agent-subprocess chain above with one
trace ID, exact parent span IDs, and distinct process resources.

Phases normally proceed in order. Phase 4 depends on Phase 3 so the POC shows
the Functional MVP. Metrics implementation may start after Phase 0, but the
primary sequence integrates it after the POC.
Expand Down
9 changes: 8 additions & 1 deletion ts/packages/agentRpc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ if (rpc) {
Every `invoke` creates one `CLIENT` span and one `SERVER` span. One-way `send`
notifications are not traced. The request may carry a versioned metadata envelope
with bounded W3C `traceparent`/`tracestate` values and the allowlisted TypeAgent
`traceId`, `sessionId`, and `activationId` correlation fields.
`agentName`, `actionName`, `traceId`, `sessionId`, and `activationId` metadata
fields. Values that fail bounds, character validation, or telemetry secret
filtering are omitted.

Outbound metadata and inbound trust are separate opt-ins. Enable each only for an
approved destination or transport:
Expand All @@ -116,6 +118,11 @@ composition roots must deliberately thread these options to TypeAgent-owned IPC
channels; adding the envelope type alone does not activate cross-process
parenting.

Code shared with browser RPC consumers must import active TypeAgent trace
metadata from `@typeagent/telemetry/traceContext`. The main
`@typeagent/telemetry` entry point is Node-only because it includes provider and
exporter lifecycle support.

Cancellation continues to use each application protocol's existing mechanism.
For example, agent actions send `cancelAction`, abort the server handler, and
cause the original invoke to reject with `AbortError`. The RPC SERVER and CLIENT
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentRpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@opentelemetry/api": "1.9.0",
"@typeagent/agent-sdk": "workspace:*",
"@typeagent/common-utils": "workspace:*",
"@typeagent/telemetry": "workspace:*",
"debug": "^4.4.0"
},
"devDependencies": {
Expand Down
153 changes: 123 additions & 30 deletions ts/packages/agentRpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,21 @@ import {
AgentInvokeFunctions,
ContextParams,
} from "./types.js";
import { createRpc } from "./rpc.js";
import {
createRpc,
type RpcCorrelationFields,
type RpcInvocation,
type RpcOptions,
} from "./rpc.js";
import { ChannelProvider } from "./common.js";
import { getObjectProperty, uint8ArrayToBase64 } from "@typeagent/common-utils";
import { AgentInterfaceFunctionName } from "./server.js";
import { randomUUID } from "crypto";
import { getActiveTypeAgentSpanAttributes } from "@typeagent/telemetry/traceContext";

export type AgentRpcOptions = {
trustedContextPropagation?: boolean;
};

/**
* Race a promise against an AbortSignal. If the signal fires before the
Expand Down Expand Up @@ -147,44 +157,55 @@ function getOptionsFunctions(options?: any): string[] | undefined {
return funcs.length > 0 ? funcs : undefined;
}

function createOptionsRpc(channelProvider: ChannelProvider, name: string) {
function createOptionsRpc(
channelProvider: ChannelProvider,
name: string,
options?: AgentRpcOptions,
) {
const channel = channelProvider.createChannel(`options:${name}`);
const optionsMap = createObjectMap();
return {
optionsMap,
rpc: createRpc(name, channel, {
callback: async (param: {
id: number;
name: string;
args: any[];
}) => {
const options: any = optionsMap.get(param.id);
let thisObject: any = undefined;
let fn: (...args: any[]) => any;
const name = param.name;
if (name === "") {
fn = options;
} else {
const names = param.name.split(".");
if (names.length === 1) {
thisObject = options;
fn = options[name];
rpc: createRpc(
name,
channel,
{
callback: async (param: {
id: number;
name: string;
args: any[];
}) => {
const options: any = optionsMap.get(param.id);
let thisObject: any = undefined;
let fn: (...args: any[]) => any;
const name = param.name;
if (name === "") {
fn = options;
} else {
const funcName = names.pop();
thisObject = getObjectProperty(options, name);
fn = thisObject[funcName!];
const names = param.name.split(".");
if (names.length === 1) {
thisObject = options;
fn = options[name];
} else {
const funcName = names.pop();
thisObject = getObjectProperty(options, name);
fn = thisObject[funcName!];
}
}
}
return fn.call(thisObject, ...param.args);
return fn.call(thisObject, ...param.args);
},
},
}),
undefined,
getTrustedRpcOptions(options),
),
};
}

export async function createAgentRpcClient(
name: string,
channelProvider: ChannelProvider,
agentInterface: AgentInterfaceFunctionName[],
options?: AgentRpcOptions,
) {
const channel = channelProvider.createChannel(`agent:${name}`);
const contextMap = createObjectMap<SessionContext<ShimContext>>();
Expand All @@ -211,16 +232,16 @@ export async function createAgentRpcClient(

const actionContextMap = createObjectMap<ActionContext<ShimContext>>();
let optionsRpc: ReturnType<typeof createOptionsRpc> | undefined;
function getOptionsCallBack(options?: any) {
const functions = getOptionsFunctions(options);
function getOptionsCallBack(initOptions?: any) {
const functions = getOptionsFunctions(initOptions);
if (functions === undefined) {
return undefined;
}
if (optionsRpc === undefined) {
optionsRpc = createOptionsRpc(channelProvider, name);
optionsRpc = createOptionsRpc(channelProvider, name, options);
}
return {
id: optionsRpc.optionsMap.getId(options),
id: optionsRpc.optionsMap.getId(initOptions),
functions,
};
}
Expand Down Expand Up @@ -295,6 +316,7 @@ export async function createAgentRpcClient(
param.name,
channelProvider,
param.agentInterface,
options,
),
);
} catch (e: any) {
Expand Down Expand Up @@ -570,12 +592,21 @@ export async function createAgentRpcClient(
},
};

const rpcOptions = getTrustedRpcOptions(options, (invocation) =>
getAgentRpcCorrelation(name, invocation),
);
const rpc = createRpc<
AgentInvokeFunctions,
AgentCallFunctions,
AgentContextInvokeFunctions,
AgentContextCallFunctions
>(name, channel, agentContextInvokeHandlers, agentContextCallHandlers);
>(
name,
channel,
agentContextInvokeHandlers,
agentContextCallHandlers,
rpcOptions,
);

// The shim needs to implement all the APIs regardless whether the actual agent
// has that API. We remove remove it the one that is not necessary below.
Expand Down Expand Up @@ -843,3 +874,65 @@ export async function createAgentRpcClient(

return result;
}

function getTrustedRpcOptions(
options: AgentRpcOptions | undefined,
getCorrelationFields?: (
invocation: RpcInvocation,
) => RpcCorrelationFields | undefined,
): RpcOptions | undefined {
return options?.trustedContextPropagation === true
? {
tracing: {
propagateContext: true,
trustRemoteContext: true,
...(getCorrelationFields === undefined
? undefined
: { getCorrelationFields }),
},
}
: undefined;
}

function getAgentRpcCorrelation(
agentName: string,
invocation: RpcInvocation,
): RpcCorrelationFields {
const active = getActiveTypeAgentSpanAttributes();
return {
agentName,
...(active?.actionName === undefined
? getInvocationActionName(invocation)
: { actionName: active.actionName }),
...(active?.traceId === undefined
? undefined
: { traceId: active.traceId }),
...(active?.sessionId === undefined
? undefined
: { sessionId: active.sessionId }),
...(active?.activationId === undefined
? undefined
: { activationId: active.activationId }),
};
}

function getInvocationActionName(
invocation: RpcInvocation,
): { actionName: string } | undefined {
if (
invocation.method !== "executeAction" &&
invocation.method !== "validateWildcardMatch"
) {
return undefined;
}
const param = invocation.args[0];
if (param === null || typeof param !== "object") {
return undefined;
}
const action = (param as { action?: unknown }).action;
if (action === null || typeof action !== "object") {
return undefined;
}
const actionName = (action as { actionName?: unknown }).actionName;
return typeof actionName === "string" ? { actionName } : undefined;
}
Loading
Loading