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
13 changes: 13 additions & 0 deletions .changeset/gentle-pandas-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"eve": patch
---

Split the OpenTelemetry authoring surface into `otel()` and `otelIntegration()`,
exported from the new `eve/instrumentation/otel` entrypoint. `otel()` declares
the settings a process can only hold one of — resource, sampler, propagators,
`functionId`, `traceChannelRequests` — and an integration declares one
destination, of which an agent may have as many as it has files. Passing
`traceExporter` to an integration wraps it in eve's batching span processor, so
a hosted backend is a one-liner. Local traces remain enabled by default in
development, and Agent Runs uses Vercel's runtime transport by default in
production. Reachable only with `experimental.instrumentationProviders` on.
6 changes: 6 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@
"import": "./dist/src/public/instrumentation/index.js",
"default": "./dist/src/public/instrumentation/index.js"
},
"./instrumentation/otel": {
"types": "./dist/src/public/instrumentation/otel.d.ts",
"import": "./dist/src/public/instrumentation/otel.js",
"default": "./dist/src/public/instrumentation/otel.js"
},
"./schedules": {
"types": "./dist/src/public/schedules/index.d.ts",
"import": "./dist/src/public/schedules/index.js",
Expand Down Expand Up @@ -328,6 +333,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 Down Expand Up @@ -67,6 +76,7 @@ export declare const propagation: {
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,11 +5,29 @@ 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
Expand Down Expand Up @@ -40,7 +58,7 @@ export interface Configuration {
readonly instrumentations?: readonly unknown[];
readonly propagators?: readonly PropagatorOrName[];
readonly serviceName?: string;
readonly spanProcessors?: readonly SpanProcessor[];
readonly spanProcessors?: readonly SpanProcessorOrName[];
readonly traceSampler?: SamplerOrName;
}

Expand Down
10 changes: 10 additions & 0 deletions packages/eve/src/harness/instrumentation-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createInstrumentationSetupContext } from "#harness/instrumentation-setup-context.js";
import { activateOtelSettings } from "#harness/otel-settings.js";
import type { InstrumentationDefinition } from "#public/instrumentation/index.js";

/**
Expand Down Expand Up @@ -42,6 +43,15 @@ export async function registerInstrumentationConfig(
input: { readonly agentName: string },
): Promise<void> {
globalContainer[INSTRUMENTATION_CONFIG_GLOBAL_KEY] = config;
// The presence of a config is what turns telemetry on in this layout, so the
// settings the harness reads are activated with it rather than by a
// registered pipeline — this layout leaves `registerOTel` to `setup`.
activateOtelSettings({
functionId: config.functionId,
recordInputs: config.recordInputs,
recordOutputs: config.recordOutputs,
traceChannelRequests: config.traceChannelRequests === true,
});
await config.setup?.(createInstrumentationSetupContext(input.agentName));
}

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 @@ -256,6 +256,9 @@ export interface InstrumentationProviderDefinition {
readonly "turn.failed"?: InstrumentationEventHandler<InstrumentationTurnTerminalEvent>;
readonly "turn.started"?: InstrumentationEventHandler<InstrumentationTurnStartedEvent>;
};
readonly flush?: () => void | PromiseLike<void>;
readonly name?: string;
readonly shutdown?: () => void | PromiseLike<void>;
}

/** Events that carry an operation `id`, pairing a start with its terminal. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";

import {
finalizeInstrumentationProviders,
getInstrumentationProviders,
registerInstrumentationProvider,
seedInstrumentationProviders,
} from "#harness/instrumentation-providers.js";
import { DEVELOPMENT_WORKER_APP_ROOT_ENV } from "#internal/workflow/development-world-protocol.js";
import { otelIntegration } from "#public/instrumentation/otel.js";

const temporaryDirectories: string[] = [];

afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});

describe("instrumentation provider local default", () => {
it("registers default local traces and an authored destination in one pipeline", async () => {
const appRoot = await mkdtemp(join(tmpdir(), "eve-provider-local-"));
temporaryDirectories.push(appRoot);
vi.stubEnv(DEVELOPMENT_WORKER_APP_ROOT_ENV, appRoot);
vi.stubEnv("EVE_TRACES", "off");

seedInstrumentationProviders();
await registerInstrumentationProvider({
agentName: "weather",
slot: "backend",
value: otelIntegration(),
});

const runtime = finalizeInstrumentationProviders({ serviceName: "weather" });
await runtime.forceFlush();
await runtime.shutdown();

expect(getInstrumentationProviders().map(({ slot }) => slot)).toEqual(["local", "backend"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import {
finalizeInstrumentationProviders,
getInstrumentationProviders,
registerInstrumentationProvider,
seedInstrumentationProviders,
} from "#harness/instrumentation-providers.js";
import { localTraces } from "#public/instrumentation/otel.js";

afterEach(() => {
vi.unstubAllEnvs();
});

describe("instrumentation provider production defaults", () => {
it("keeps authored local traces inert beside Agent Runs", async () => {
vi.stubEnv("EVE_DEV_WORKER_APP_ROOT", undefined);
vi.stubEnv("VERCEL_ENV", "production");

seedInstrumentationProviders();
await registerInstrumentationProvider({
agentName: "weather",
slot: "local",
value: localTraces(),
});

const runtime = finalizeInstrumentationProviders({ serviceName: "weather" });
await runtime.forceFlush();
await runtime.shutdown();

expect(getInstrumentationProviders().map(({ slot }) => slot)).toEqual(["agent-runs", "local"]);
});
});
93 changes: 93 additions & 0 deletions packages/eve/src/harness/instrumentation-providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import {
finalizeInstrumentationProviders,
getInstrumentationProviders,
registerInstrumentationProvider,
seedInstrumentationProviders,
shutdownInstrumentationProviders,
} from "#harness/instrumentation-providers.js";
import { DEVELOPMENT_WORKER_APP_ROOT_ENV } from "#internal/workflow/development-world-protocol.js";
import { defineInstrumentation } from "#public/instrumentation/index.js";
import { agentRuns, localTraces, otelIntegration } from "#public/instrumentation/otel.js";
import {
disableInstrumentation,
type ProviderSetupContext,
} from "#public/instrumentation/provider.js";

const REGISTRY_GLOBAL_KEY = Symbol.for("eve.harness-instrumentation-providers");
const RUNTIME_GLOBAL_KEY = Symbol.for("eve.instrumentation-runtime");

function register(slot: string, value: unknown): Promise<void> {
return registerInstrumentationProvider({ agentName: "weather-agent", slot, value });
Expand All @@ -20,6 +26,7 @@ describe("registerInstrumentationProvider", () => {
beforeEach(() => {
vi.unstubAllEnvs();
delete (globalThis as Record<symbol, unknown>)[REGISTRY_GLOBAL_KEY];
delete (globalThis as Record<symbol, unknown>)[RUNTIME_GLOBAL_KEY];
});

it("registers a provider under its slot", async () => {
Expand Down Expand Up @@ -93,3 +100,89 @@ describe("registerInstrumentationProvider", () => {
);
});
});

describe("seedInstrumentationProviders", () => {
beforeEach(() => {
vi.unstubAllEnvs();
delete (globalThis as Record<symbol, unknown>)[REGISTRY_GLOBAL_KEY];
delete (globalThis as Record<symbol, unknown>)[RUNTIME_GLOBAL_KEY];
vi.stubEnv("EVE_TRACES", "off");
vi.stubEnv("VERCEL_ENV", undefined);
vi.stubEnv(DEVELOPMENT_WORKER_APP_ROOT_ENV, "/tmp/eve-seed-test");
});

it("keeps default local traces beside an authored destination", async () => {
seedInstrumentationProviders();
await register("backend", otelIntegration());

expect(getInstrumentationProviders().map(({ slot }) => slot)).toEqual(["local", "backend"]);
});

it("seeds Agent Runs only in Vercel production", () => {
vi.stubEnv(DEVELOPMENT_WORKER_APP_ROOT_ENV, undefined);
vi.stubEnv("VERCEL_ENV", "production");

seedInstrumentationProviders();

expect(getInstrumentationProviders().map(({ slot }) => slot)).toEqual(["agent-runs"]);
});

it("lets an authored reserved slot reconfigure or disable its default", async () => {
seedInstrumentationProviders();
const authored = localTraces({ recordInputs: false });
await register("local", authored);
expect(getInstrumentationProviders()).toEqual([{ provider: authored, slot: "local" }]);

await register("local", disableInstrumentation());
expect(getInstrumentationProviders()).toEqual([]);
});

it("lets an authored Agent Runs slot narrow the production default", async () => {
vi.stubEnv(DEVELOPMENT_WORKER_APP_ROOT_ENV, undefined);
vi.stubEnv("VERCEL_ENV", "production");
seedInstrumentationProviders();
const authored = agentRuns({ recordOutputs: false });

await register("agent-runs", authored);

expect(getInstrumentationProviders()).toEqual([{ provider: authored, slot: "agent-runs" }]);
});
});

describe("finalizeInstrumentationProviders", () => {
beforeEach(() => {
vi.unstubAllEnvs();
delete (globalThis as Record<symbol, unknown>)[REGISTRY_GLOBAL_KEY];
delete (globalThis as Record<symbol, unknown>)[RUNTIME_GLOBAL_KEY];
});

it("installs a bus for authored providers without an OpenTelemetry destination", async () => {
const started = vi.fn();
await register("rows", defineInstrumentation({ events: { "turn.started": started } }));

const runtime = finalizeInstrumentationProviders({ serviceName: "weather-agent" });
await runtime.hooks.publish({
rootSessionId: "session-1",
sequence: 0,
sessionId: "session-1",
turnId: "turn-1",
type: "turn.started",
});

expect(started).toHaveBeenCalledOnce();
});

it("drives authored flush and shutdown hooks", async () => {
const flush = vi.fn();
const shutdown = vi.fn();
await register("rows", defineInstrumentation({ flush, shutdown }));

const runtime = finalizeInstrumentationProviders({ serviceName: "weather-agent" });
await runtime.forceFlush();
await shutdownInstrumentationProviders();
await shutdownInstrumentationProviders();

expect(flush).toHaveBeenCalledOnce();
expect(shutdown).toHaveBeenCalledOnce();
});
});
Loading