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
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@opentelemetry/exporter-logs-otlp-proto": "^0.221.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.221.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.221.0",
"@opentelemetry/otlp-transformer": "^0.221.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.221.0",
"@opentelemetry/sdk-metrics": "^2.9.0",
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@opentelemetry/exporter-logs-otlp-proto": "catalog:",
"@opentelemetry/exporter-metrics-otlp-proto": "catalog:",
"@opentelemetry/exporter-trace-otlp-proto": "catalog:",
"@opentelemetry/otlp-transformer": "catalog:",
"@opentelemetry/resources": "catalog:",
"@opentelemetry/sdk-logs": "catalog:",
"@opentelemetry/sdk-metrics": "catalog:",
Expand Down
16 changes: 15 additions & 1 deletion packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import {
emitTelemetryEvent,
initTelemetryExport,
isTelemetryExportEnabled,
resolveCloudTelemetryTransport,
type SessionMode,
setActiveTelemetrySessionId,
trackSessionLifecycle,
Expand Down Expand Up @@ -1531,7 +1532,20 @@ export async function runRootCommand(
// agent loop's telemetry hooks so traces, run-level metrics, and structured
// logs have source events to export. Content capture remains governed by
// OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.
await logger.time("initTelemetryExport", () => initTelemetryExport({ settings: settingsInstance }));
//
// `cloud` carries the bearer for the Aura telemetry tier. It resolves to
// undefined unless that tier actually wins the destination AND a signed-in
// cloud session exists, so the built-in and operator tiers are untouched and
// no token database is opened on the common path.
const cloudTelemetryTransport = await logger.time("resolveCloudTelemetryTransport", () =>
resolveCloudTelemetryTransport(settingsInstance),
);
await logger.time("initTelemetryExport", () =>
initTelemetryExport({
settings: settingsInstance,
...(cloudTelemetryTransport ? { cloud: { transport: cloudTelemetryTransport } } : {}),
}),
);
if (isTelemetryExportEnabled()) {
sessionOptions.telemetry = createTelemetryExportConfig(sessionOptions.telemetry);
// Subscription utilization: every fresh usage-limit snapshot (polled report
Expand Down
183 changes: 183 additions & 0 deletions packages/coding-agent/src/telemetry/authorized-exporters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* OTLP exporters for the Aura telemetry tier.
*
* The stock OTLP exporters take static headers at construction; an Aura
* access token lives ≤15 minutes and may only ever be attached by
* `TokenManager.authorizedFetch` (the single bearer-attachment point). These
* exporters serialize with the same otlp-transformer the stock exporters
* use, then send through an injected `authorizedFetch` — fresh token per
* export, 401-refresh-retry and redirect guards included. Only the Aura
* tier constructs them (init.ts); every other destination keeps the stock
* exporters and never sees a credential.
*
* `sendAuthorized` mirrors, in spirit, the stock otlp-exporter-base retry
* behavior: a 429/502/503/504 or a network-level rejection is retried with
* jittered exponential backoff (honoring `Retry-After` when the collector
* sends one) up to {@link MAX_ATTEMPTS} attempts, and every attempt is bounded
* by {@link EXPORT_TIMEOUT_MS} — otherwise `BatchLogRecordProcessor` marks a
* hung export FAILED but never re-queues it, so a transient collector blip or
* a wedged connection would permanently drop the batch, billing records
* included.
*/
import { type ExportResult, ExportResultCode } from "@opentelemetry/core";
import {
ProtobufLogsSerializer,
ProtobufMetricsSerializer,
ProtobufTraceSerializer,
} from "@opentelemetry/otlp-transformer";
import type { LogRecordExporter, ReadableLogRecord } from "@opentelemetry/sdk-logs";
import type { PushMetricExporter, ResourceMetrics } from "@opentelemetry/sdk-metrics";
import { AggregationTemporality } from "@opentelemetry/sdk-metrics";
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";

export interface AuraTelemetryTransport {
authorizedFetch(
url: string,
init: {
method?: string;
headers?: Bun.HeadersInit;
body?: Bun.BodyInit;
eligibleOrigin?: string;
signal?: AbortSignal;
},
): Promise<Response>;
}

export interface AuthorizedExporterOptions {
/** Full signal URL, e.g. https://aura.elide.events/v1/logs. */
url: string;
transport: AuraTelemetryTransport;
/**
* Backoff delay override, for tests: given the computed delay in
* milliseconds, resolve whenever the test is ready to let the retry
* proceed. Defaults to a real `Bun.sleep`.
*/
sleep?: (ms: number) => Promise<void>;
}

/** Up to 3 attempts total (the initial send plus 2 retries). */
const MAX_ATTEMPTS = 3;
/** Collector statuses worth retrying — transient overload/unavailability, per the OTLP spec. */
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 5000;
const BACKOFF_MULTIPLIER = 2;
/** Jitter fraction applied symmetrically around the computed backoff. */
const JITTER = 0.2;
/** Per-attempt bound, matching the stock OTLP exporters' default export timeout. */
const EXPORT_TIMEOUT_MS = 10_000;

function jitteredBackoff(baseMs: number): number {
const jitter = Math.random() * (2 * JITTER) - JITTER;
return Math.max(0, Math.min(baseMs * (1 + jitter), MAX_BACKOFF_MS));
}

/** `Retry-After`, in ms: an integer is seconds, otherwise an HTTP-date. Undefined when absent or unparsable. */
function parseRetryAfterMs(value: string | null): number | undefined {
if (!value) return undefined;
const seconds = Number.parseInt(value, 10);
if (Number.isInteger(seconds) && String(seconds) === value.trim()) return Math.max(0, seconds * 1000);
const delay = new Date(value).getTime() - Date.now();
return Number.isNaN(delay) ? undefined : Math.max(0, delay);
}

function defaultSleep(ms: number): Promise<void> {
return Bun.sleep(ms);
}

/** Exported for tests (like errorEventFromLog in init.ts). */
export async function sendAuthorized(
options: AuthorizedExporterOptions,
body: Uint8Array | undefined,
resultCallback: (result: ExportResult) => void,
): Promise<void> {
if (body === undefined || body.byteLength === 0) {
resultCallback({ code: ExportResultCode.SUCCESS });
return;
}
const eligibleOrigin = new URL(options.url).origin;
const sleep = options.sleep ?? defaultSleep;
let backoffMs = INITIAL_BACKOFF_MS;

for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const lastAttempt = attempt === MAX_ATTEMPTS;
try {
const response = await options.transport.authorizedFetch(options.url, {
method: "POST",
headers: { "content-type": "application/x-protobuf" },
body: body as unknown as Bun.BodyInit,
eligibleOrigin,
signal: AbortSignal.timeout(EXPORT_TIMEOUT_MS),
});
await response.body?.cancel();
if (response.ok) {
resultCallback({ code: ExportResultCode.SUCCESS });
return;
}
if (!RETRYABLE_STATUSES.has(response.status) || lastAttempt) {
resultCallback({ code: ExportResultCode.FAILED, error: new Error(`collector status ${response.status}`) });
return;
}
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
await sleep(retryAfterMs ?? jitteredBackoff(backoffMs));
} catch (error) {
if (lastAttempt) {
resultCallback({
code: ExportResultCode.FAILED,
error: error instanceof Error ? error : new Error(String(error)),
});
return;
}
await sleep(jitteredBackoff(backoffMs));
}
backoffMs *= BACKOFF_MULTIPLIER;
}
}

export class AuthorizedLogExporter implements LogRecordExporter {
constructor(private readonly options: AuthorizedExporterOptions) {}

export(logs: ReadableLogRecord[], resultCallback: (result: ExportResult) => void): void {
sendAuthorized(
this.options,
logs.length === 0 ? undefined : ProtobufLogsSerializer.serializeRequest(logs),
resultCallback,
);
}

/** Nothing buffered — every export() call sends immediately. */
async forceFlush(): Promise<void> {}

async shutdown(): Promise<void> {}
}

export class AuthorizedTraceExporter implements SpanExporter {
constructor(private readonly options: AuthorizedExporterOptions) {}

export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
sendAuthorized(
this.options,
spans.length === 0 ? undefined : ProtobufTraceSerializer.serializeRequest(spans),
resultCallback,
);
}

async shutdown(): Promise<void> {}
}

export class AuthorizedMetricExporter implements PushMetricExporter {
constructor(private readonly options: AuthorizedExporterOptions) {}

export(metrics: ResourceMetrics, resultCallback: (result: ExportResult) => void): void {
sendAuthorized(this.options, ProtobufMetricsSerializer.serializeRequest(metrics), resultCallback);
}

/** Cumulative, matching the stock exporter default — billing reads logs, not metrics. */
selectAggregationTemporality(): AggregationTemporality {
return AggregationTemporality.CUMULATIVE;
}

async forceFlush(): Promise<void> {}

async shutdown(): Promise<void> {}
}
66 changes: 66 additions & 0 deletions packages/coding-agent/src/telemetry/cloud-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Resolves the cloud transport the Aura telemetry tier authenticates with.
*
* This is the handoff `InitTelemetryOptions.cloud` documents: when a signed-in
* cloud session exists, telemetry exports go through the authorized exporters
* (fresh bearer per export via `TokenManager.authorizedFetch`) instead of the
* stock unauthenticated ones. The cloud relay accepts anonymous OTLP either
* way; the bearer is what lets it attribute usage to an account.
*
* Three properties are load-bearing:
*
* - **The pure gate runs first.** `resolveAuraAuthorizedUrl` already encodes
* the full destination precedence (env wins, an explicit `telemetry.endpoint`
* outranks the Aura tier, the built-in tier never authenticates, and
* `cloud.telemetry.enabled` gates the tier). If no signal would use the Aura
* tier there is nothing to authenticate, and we must not open the token
* database — startup cost is paid only by installs that will actually use it.
* - **Cloud auth stays off the CLI entry graph.** `token-store` and `auth` are
* reached through lazy `import()`, per the ownership note in `src/cloud/index.ts`.
* - **Never throws.** Telemetry is best-effort; a missing database, an invalid
* `AURA_DOMAIN`, or a locked store degrades to unauthenticated export, never
* to a failed startup.
*
* Returns `undefined` when no session is available — which today is always,
* since nothing in the CLI creates one yet. The moment an Elide Cloud login
* lands, authenticated export starts working with no change here.
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import type { AuraTelemetryTransport } from "./authorized-exporters";
import { resolveAuraAuthorizedUrl, type TelemetrySignal } from "./init";

const SIGNALS: readonly TelemetrySignal[] = ["trace", "log", "metric"];

export async function resolveCloudTelemetryTransport(
settings: Pick<Settings, "get"> | undefined,
processEnv: Record<string, string | undefined> = process.env,
): Promise<AuraTelemetryTransport | undefined> {
// Pure and cheap: no I/O, no database, no import of the cloud auth graph.
const usesAuraTier = SIGNALS.some(signal => resolveAuraAuthorizedUrl(signal, settings, processEnv) !== undefined);
if (!usesAuraTier || !settings) return undefined;

try {
const { auraDeploymentFor, readCloudSwitches, resolveAuraDeployment } = await import("../cloud/deployment");
const deployment = resolveAuraDeployment({ env: processEnv });
// `account` is the consumer that owns the auth origin; its own switch
// gates it, so disabling cloud account access also disables this.
const authOrigin = auraDeploymentFor("account", deployment, readCloudSwitches(settings)).authOrigin?.url;
if (!authOrigin) return undefined;

const [{ AuraTokenStore }, { AuraAuthClient }] = await Promise.all([
import("../cloud/token-store"),
import("../cloud/auth"),
]);
const client = new AuraAuthClient({ authOrigin, store: await AuraTokenStore.open() });
// Signed out: hand back nothing rather than a manager that would fail on
// every export. The stock exporters then carry telemetry unauthenticated.
if (!client.status().signedIn) return undefined;
return client.manager;
} catch (error) {
logger.warn("telemetry: no cloud session available; exporting unauthenticated", {
error: String(error),
});
return undefined;
}
}
1 change: 1 addition & 0 deletions packages/coding-agent/src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** Telemetry subsystem: event bus, resource identity, and the OTLP bootstrap. */
export * from "./cloud-session";
export * from "./events";
export * from "./identity";
export * from "./init";
Expand Down
Loading
Loading