Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/otel-pipeline-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

eve now assembles its local OpenTelemetry runtime from declarative singleton settings and ordered destinations. The local trace spool is an ordinary span processor, and tracer-provider ownership, flushing, and shutdown are managed centrally without changing recorded spans.
1 change: 1 addition & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@
"@eve/catalog": "workspace:*",
"@nuxt/kit": "^4.0.0",
"@opentelemetry/context-async-hooks": "catalog:",
"@opentelemetry/core": "catalog:",
"@opentelemetry/otlp-transformer": "0.214.0",
"@opentelemetry/sdk-trace-base": "catalog:",
"@photon-ai/chat-adapter-imessage": "3.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export interface Tracer {
): Span;
}

export interface Context {}
export interface Context {
setValue(key: symbol, value: unknown): Context;
}

export declare function createContextKey(description: string): symbol;

export declare const ROOT_CONTEXT: Context;

Expand All @@ -40,6 +44,11 @@ export declare enum SpanStatusCode {
ERROR = 2,
}

export declare enum TraceFlags {
NONE = 0,
SAMPLED = 1,
}

export declare const context: {
active(): Context;
with<T>(context: Context, fn: () => T): T;
Expand All @@ -55,13 +64,19 @@ export interface TextMapGetter<Carrier = unknown> {
keys(carrier: Carrier): string[];
}

export interface TextMapSetter<Carrier = unknown> {
set(carrier: Carrier, key: string, value: string): void;
}

export declare const propagation: {
extract<Carrier>(context: Context, carrier: Carrier, getter: TextMapGetter<Carrier>): Context;
inject<Carrier>(context: Context, carrier: Carrier, setter: TextMapSetter<Carrier>): void;
};

export declare const trace: {
getActiveSpan(): Span | undefined;
getTracer(name: string, version?: string): Tracer;
getTracerProvider(): unknown;
setSpan(context: Context, span: Span): Context;
wrapSpanContext(spanContext: SpanContext): Span;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,61 @@ export interface SpanProcessor {
shutdown(): Promise<void>;
}

export type SpanProcessorOrName = SpanProcessor | "auto";

export interface IdGenerator {
generateSpanId(): string;
generateTraceId(): string;
}

/**
* A `SpanExporter`. Structural for the same reason the propagator is: the
* instance comes from whichever `@opentelemetry/*` build the app installed, and
* eve only ever hands it spans and waits for the callback.
*
* `code` is `ExportResultCode`: `0` succeeded, `1` failed.
*/
export interface SpanExporter {
export(
spans: readonly unknown[],
resultCallback: (result: { code: number; error?: Error }) => void,
): void;
forceFlush?(): Promise<void>;
shutdown(): Promise<void>;
}

/**
* A `TextMapPropagator`, or one of the names `@vercel/otel` resolves for you.
* Structural rather than imported: the instance comes from whichever
* `@opentelemetry/*` build the app installed, not from eve's.
*/
export type PropagatorOrName =
| { inject(...args: never[]): void; extract(...args: never[]): unknown; fields(): string[] }
| "auto"
| "none"
| "tracecontext"
| "baggage";

/** A `Sampler`, or one of the names `@vercel/otel` resolves for you. */
export type SamplerOrName =
| { shouldSample(...args: never[]): unknown; toString(): string }
| "auto"
| "always_off"
| "always_on"
| "parentbased_always_off"
| "parentbased_always_on"
| "parentbased_traceidratio"
| "traceidratio";

export interface Configuration {
readonly attributes?: Readonly<Record<string, unknown>>;
readonly autoDetectResources?: boolean;
readonly idGenerator?: IdGenerator;
readonly instrumentations?: readonly unknown[];
readonly propagators?: readonly ["none"];
readonly propagators?: readonly PropagatorOrName[];
readonly serviceName?: string;
readonly spanProcessors?: readonly SpanProcessor[];
readonly spanProcessors?: readonly SpanProcessorOrName[];
readonly traceSampler?: SamplerOrName;
}

export declare function registerOTel(configuration?: Configuration | string): void;
15 changes: 10 additions & 5 deletions packages/eve/src/harness/instrumentation-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
InstrumentationDefinition,
InstrumentationSetupContext,
} from "#public/instrumentation/index.js";
import { activateOtelSettings } from "#harness/otel-settings.js";

/**
* Process-global store for the authored instrumentation config.
Expand Down Expand Up @@ -36,14 +37,18 @@ const globalContainer = globalThis as typeof globalThis & InstrumentationConfigG
*
* @internal — not part of the public API.
*/
export function registerInstrumentationConfig(
export async function registerInstrumentationConfig(
config: InstrumentationDefinition,
context: InstrumentationSetupContext,
): void {
if (config.setup !== undefined) {
config.setup(context);
}
): Promise<void> {
globalContainer[INSTRUMENTATION_CONFIG_GLOBAL_KEY] = config;
activateOtelSettings({
functionId: config.functionId,
recordInputs: config.recordInputs,
recordOutputs: config.recordOutputs,
traceChannelRequests: config.traceChannelRequests === true,
});
await config.setup?.(context);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/eve/src/harness/instrumentation-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export type InstrumentationEventHandler<TEvent> = (event: TEvent) => void | Prom

/** Internal provider shape mirrored by the future public hook contract. */
export interface InstrumentationProviderDefinition {
readonly name?: string;
readonly events?: {
readonly "step.attempt.started"?: InstrumentationEventHandler<InstrumentationStepAttemptStartedEvent>;
readonly "step.attempt.completed"?: InstrumentationEventHandler<InstrumentationStepAttemptTerminalEvent>;
Expand All @@ -314,6 +315,8 @@ export interface InstrumentationProviderDefinition {
readonly "turn.failed"?: InstrumentationEventHandler<InstrumentationTurnTerminalEvent>;
readonly "turn.started"?: InstrumentationEventHandler<InstrumentationTurnStartedEvent>;
};
readonly flush?: () => void | PromiseLike<void>;
readonly shutdown?: () => void | PromiseLike<void>;
}

/** Events that carry an operation `id`, pairing a start with its terminal. */
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/harness/instrumentation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface InstrumentationRuntime {
readonly forceFlush: () => Promise<void>;
readonly hooks: InstrumentationHooks;
readonly runInContext: InstrumentationContextRunner;
readonly shutdown: () => Promise<void>;
}

/** Instrumentation capabilities consumed inside one harness execution. */
Expand Down
37 changes: 37 additions & 0 deletions packages/eve/src/harness/otel-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { OtelHarnessSettings } from "#tracing/otel-declaration.js";

/**
* Process-global store for the telemetry settings the harness reads per turn.
*
* It exists because the two layouts declare the same settings in different
* shapes — `agent/instrumentation.ts` as fields on the config object,
* `agent/instrumentation/otel.ts` as `otel()` options — and the harness should
* not know which one the build used. Whichever registered writes here; the
* turn loop and the channel-request wrapper only read.
*
* Its presence is also the signal that telemetry is on at all. Rooted on
* `globalThis` for the reason the other instrumentation stores are: the
* generated Nitro plugin stays external by `file://` URL while the harness
* chunk is inlined, so the two are distinct ESM module instances.
*/
const OTEL_SETTINGS_GLOBAL_KEY = Symbol.for("eve.harness-otel-settings");

interface OtelSettingsGlobal {
[OTEL_SETTINGS_GLOBAL_KEY]?: OtelHarnessSettings;
}

const globalContainer = globalThis as typeof globalThis & OtelSettingsGlobal;

/** @internal — not part of the public API. */
export function activateOtelSettings(settings: OtelHarnessSettings): void {
globalContainer[OTEL_SETTINGS_GLOBAL_KEY] = settings;
}

/**
* The active settings, or `undefined` when nothing declared telemetry.
*
* @internal — not part of the public API.
*/
export function getOtelSettings(): OtelHarnessSettings | undefined {
return globalContainer[OTEL_SETTINGS_GLOBAL_KEY];
}
38 changes: 26 additions & 12 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ vi.mock("./instrumentation-config.js", () => ({
getInstrumentationConfig: (...args: unknown[]) => mockGetInstrumentationConfig(...args),
}));

const mockGetOtelSettings = vi.fn().mockReturnValue(undefined);
vi.mock("./otel-settings.js", () => ({
getOtelSettings: (...args: unknown[]) => mockGetOtelSettings(...args),
}));

function declareTelemetry(config: Readonly<Record<string, unknown>> | undefined): void {
mockGetInstrumentationConfig.mockReturnValue(config);
mockGetOtelSettings.mockReturnValue(
config === undefined
? undefined
: { ...config, traceChannelRequests: config["traceChannelRequests"] === true },
);
}

vi.mock("./compaction.js", () => ({
compactMessages: vi.fn(),
estimateTokens: vi.fn().mockReturnValue(5000),
Expand All @@ -113,7 +127,7 @@ vi.mock("./compaction.js", () => ({
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
mockGetInstrumentationConfig.mockReturnValue(undefined);
declareTelemetry(undefined);
});

function createTestSession(overrides?: Partial<HarnessSession>): HarnessSession {
Expand Down Expand Up @@ -4356,7 +4370,7 @@ describe("createToolLoopHarness", () => {
"test.attempt": typeof input.modelInput.instructions === "string" ? "original" : "retry",
},
}));
mockGetInstrumentationConfig.mockReturnValue({
declareTelemetry({
events: {
"step.started": resolveRuntimeContext,
},
Expand Down Expand Up @@ -9418,11 +9432,11 @@ describe("createToolLoopHarness", () => {
toolResults: [{ toolCallId: "call-1", toolName: "add", output: "42" }],
});

mockGetInstrumentationConfig.mockReturnValue({});
declareTelemetry({});
const config = createTestConfig("conversation");
const runStep = createToolLoopHarness(config);
const result = await runStep(createTestSession(), { message: "add stuff" });
mockGetInstrumentationConfig.mockReturnValue(undefined);
declareTelemetry(undefined);

expect(result.next).toBe(runStep);
expect(result.session.state?.["eve.harness.turnTrace"]).toEqual({
Expand Down Expand Up @@ -9485,7 +9499,7 @@ describe("createToolLoopHarness", () => {
toolResults: [{ toolCallId: "call-1", toolName: "add", output: "42" }],
});

mockGetInstrumentationConfig.mockReturnValue({});
declareTelemetry({});
const step1Config = createTestConfig("conversation");
const step1 = createToolLoopHarness(step1Config);
const result1 = await step1(createTestSession(), { message: "add stuff" });
Expand Down Expand Up @@ -9514,7 +9528,7 @@ describe("createToolLoopHarness", () => {
const step2 = createToolLoopHarness(step2Config);
// No input — continuation step
const result2 = await step2(result1.session);
mockGetInstrumentationConfig.mockReturnValue(undefined);
declareTelemetry(undefined);

expect(result2.next).toBeNull();

Expand Down Expand Up @@ -9544,11 +9558,11 @@ describe("createToolLoopHarness", () => {
toolResults: [],
});

mockGetInstrumentationConfig.mockReturnValue({});
declareTelemetry({});
const config = createTestConfig("conversation");
const runStep = createToolLoopHarness(config);
await runStep(createTestSession(), { message: "hi" });
mockGetInstrumentationConfig.mockReturnValue(undefined);
declareTelemetry(undefined);

const agentCall = vi.mocked(ToolLoopAgent).mock.calls[0]?.[0] as {
runtimeContext?: Record<string, unknown>;
Expand Down Expand Up @@ -9623,7 +9637,7 @@ describe("createToolLoopHarness", () => {
toolCalls: [],
toolResults: [],
});
mockGetInstrumentationConfig.mockReturnValue({ recordInputs: true, recordOutputs: false });
declareTelemetry({ recordInputs: true, recordOutputs: false });
const hooks = createInstrumentationHooks([]);
const runStep = createToolLoopHarness(
createTestConfig("conversation", undefined, {
Expand Down Expand Up @@ -9682,7 +9696,7 @@ describe("createToolLoopHarness", () => {
},
};
});
mockGetInstrumentationConfig.mockReturnValue({
declareTelemetry({
events: {
"step.started": resolveRuntimeContext,
},
Expand Down Expand Up @@ -9737,7 +9751,7 @@ describe("createToolLoopHarness", () => {
toolResults: [],
});

mockGetInstrumentationConfig.mockReturnValue({
declareTelemetry({
events: {
"step.started": () => {
throw new Error("runtime context resolver failed");
Expand Down Expand Up @@ -9775,7 +9789,7 @@ describe("createToolLoopHarness", () => {
"test.step": `${input.turn.id}:${input.step.index}`,
},
}));
mockGetInstrumentationConfig.mockReturnValue({
declareTelemetry({
events: {
"step.started": resolveRuntimeContext,
},
Expand Down
Loading
Loading