Skip to content
Merged
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
26 changes: 22 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,31 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Detect root npm project
id: root-npm
run: |
if [ -f package.json ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "No root package.json; skipping root npm build for this tree."
fi
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
if: steps.root-npm.outputs.exists == 'true'
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- name: Install dependencies
if: steps.root-npm.outputs.exists == 'true'
run: |
if [ -f package-lock.json ]; then
npm ci
else
npm install --no-audit --no-fund
fi
- if: steps.root-npm.outputs.exists == 'true'
run: npm run check:node-runtime
- if: steps.root-npm.outputs.exists == 'true'
run: npm run build

package-artifact:
name: Package Artifact
Expand Down
100 changes: 99 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ When a provider is configured for Bifrost, the corresponding
(`claude-web`, `chatgpt-web`, etc.) and custom CLI executors
(`cliproxyapi`, `cursor`, `codex`, `trae`, `qoder`, `kiro`, etc.).

### Future phases (B1–B9, see PLAN.md § 2.5)
### Future phases (B1–B10, see PLAN.md § 2.5)

| Phase | Item | Status |
|---|---|---|
Expand All @@ -823,6 +823,7 @@ When a provider is configured for Bifrost, the corresponding
| B7 | Migration playbook (`docs/operations/bifrost-migration.md`) | ☐ Q3 2026 |
| B8 | Bifrost MCP client integration | ☐ Q4 2026 |
| B9 | Kill switch (fallback to chatCore if SLOs fail 7d) | 🔄 spec only |
| B10 | **OTel bridge — Tier-1 (Bifrost, Go) ⇄ Tier-2 (OmniRoute, TS) unified traces via W3C `traceparent`** | ✅ DONE 2026-06-21 |

### Decision review schedule

Expand Down Expand Up @@ -1005,6 +1006,103 @@ now actually drives the Bifrost executor.

Refs: `PLAN.md` § 2.5.2 (B9.1 row), `docs/adr/0031-bifrost-tier1-router.md`,
PR #95 (B9 close-out), PR (this turn).
## Recent Changes (B10 OTel bridge, 2026-06-21)

Implements **B10** of the v8.1 Bifrost Tier-1 router track (`PLAN.md`
§ 2.5.2). Unifies distributed traces between Tier-1 (Bifrost, Go) and
Tier-2 (OmniRoute, TS) so a single trace crosses the HTTP boundary via
the W3C `traceparent` header.

### Public API (`open-sse/observability/otelExporter.ts`)

| Export | Purpose |
|---|---|
| `getTracer(name: string)` | Returns an OTel `Tracer`. No-op when SDK not initialized. |
| `isOtelEnabled(): boolean` | `true` iff `OTEL_EXPORTER_OTLP_ENDPOINT` is set and `OTEL_SDK_DISABLED` is not truthy. |
| `recordException(span, error)` | Records an exception event on the span and sets its status to ERROR. Swallows all internal errors so the request path is never blocked. |
| `endSpanSafely(span)` | Idempotent `span.end()` wrapper that swallows errors. |
| `markSpanOk(span)` | Sets span status to OK. |
| `getOtlpEndpoint()` | Reads `OTEL_EXPORTER_OTLP_ENDPOINT` (returns `null` when unset). |
| `markOtelInitLogged()` / `_wasOtelInitLogged()` / `_resetOtelInitLoggedForTest()` | Init-log gate helpers (test-only). |

### Public API (`open-sse/observability/traceparent.ts`)

| Export | Purpose |
|---|---|
| `generateTraceparent(opts?)` | Builds a fresh W3C `traceparent` header value. Re-rolls all-zero trace/parent ids. Accepts `GenerateTraceparentOptions` (with `sampled?: boolean`) or a positional boolean. |
| `parseTraceparent(raw)` | Validates and parses a `traceparent` value. Returns a discriminated union (`{ ok: true, traceparent } | { ok: false, error, raw }`). |
| `parseTracestate(raw)` / `formatTracestate(entries)` | Round-trip for the optional `tracestate` header. |
| `formatTraceparent(tp)` / `childTraceparent(parent, childParentId)` | Build child traceparents that preserve the parent's `traceId` + `flags`. |
| `injectTraceparent(headers, tp, ts?, opts?)` | Writes `traceparent` (and optionally `tracestate`) into a headers map. Case-insensitive header detection, replaces existing entries, appends to existing `tracestate` unless `replaceTracestate: true`. |
| `readTraceparentFromHeaders(headers)` | Reads both headers back, parsed. |
| `safeParseTraceparent(raw)` | Convenience wrapper around `parseTraceparent` that returns `null` on failure. |

### Wiring

`src/instrumentation-node.ts::initOtel()` — bootstraps the OTel Node SDK
**only when** `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Dynamically imports
`@opentelemetry/sdk-node`, `@opentelemetry/exporter-trace-otlp-http`,
`@opentelemetry/resources`, `@opentelemetry/sdk-trace-base`,
`@opentelemetry/semantic-conventions`. On init failure (e.g. dep
missing), logs a single `[OTEL]` warning and stays no-op. Stashes the
SDK on `globalThis.__otelSdk` for graceful shutdown.

`initOtel()` is wired into `registerNodejs()` (the Node startup chain)
right after the global fetch-proxy patch and before `ensureSecrets()`.

`open-sse/observability/bifrostSpan.ts::withBifrostSpan()` — wraps a
Bifrost HTTP call in a CLIENT span; injects the traceparent into the
outbound headers. The trace-id / parent-id come from the active span
context when the SDK is up, from the caller's `parentTraceparent`
override when not, or from a freshly minted traceparent as last resort.

`open-sse/observability/comboSpan.ts::withComboSpan()` — wraps
`handleComboChat` in an INTERNAL parent span and uses `context.with()`
+ `trace.setSpan()` so every parallel provider span (including the
Bifrost spans) automatically attaches as a child.

### Activation

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
export OTEL_SERVICE_NAME=omniroute # optional, defaults to "omniroute"
```

When the env var is unset, all `getTracer()` calls return the
`@opentelemetry/api` no-op tracer, every span is non-recording, and
the dispatcher path is unaffected (no measurable overhead).

`OTEL_SDK_DISABLED=true` overrides the endpoint and forces the no-op
path even when the endpoint is configured.

### Refactors

Replaces hand-rolled `traceparent` construction in three call sites.
All three now import `generateTraceparent` from
`@omniroute/open-sse/observability/traceparent.ts`:

| File | Before | After |
|---|---|---|
| `open-sse/executors/cursor.ts:620` | `\`00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01\`` | `generateTraceparent({ sampled: true })` |
| `open-sse/executors/grok-web.ts` | Local `randomHex()` helper + hand-built `\`00-${traceId}-${spanId}-00\`` | `generateTraceparent({ sampled: false })`. The local helper is removed. |
| `src/lib/providers/validation.ts` | Inline `randomHex = (n) => {…}` + hand-built `\`00-${traceId}-${spanId}-00\`` | `generateTraceparent({ sampled: false })`. The inline helper is removed. |

### Tests

| File | Coverage |
|---|---|
| `tests/unit/otel-exporter.test.ts` | `isOtelEnabled` honors both env vars; `getTracer` returns no-op by default; `recordException` swallows errors; helpers are test-isolated. |
| `tests/unit/traceparent.test.ts` | W3C spec edge cases: all-zero rejection, lowercase hex, malformed split, version `00` strict, `tracestate` round-trip, `injectTraceparent` case-insensitivity + tracestate append/replace. |
| `tests/unit/bifrost-span.test.ts` | Span created with right name + attributes; traceparent injected into fetch headers; wrapper passes through inner return; throw path records exception. |
| `tests/unit/combo-span.test.ts` | Parent span attaches via `context.with`; resolved model extracted from `Response` and object shapes; failure path records exception + sets ERROR status. |
| `tests/unit/instrumentation-node.test.ts` | `initOtel()` returns `false` when env unset; logs once; continues no-op when SDK deps are missing. |

Refs: `docs/adr/0031-bifrost-tier1-router.md`, `PLAN.md` § 2.5.2 (B10),
[`open-sse/observability/otelExporter.ts`](open-sse/observability/otelExporter.ts),
[`open-sse/observability/traceparent.ts`](open-sse/observability/traceparent.ts),
[`open-sse/observability/bifrostSpan.ts`](open-sse/observability/bifrostSpan.ts),
[`open-sse/observability/comboSpan.ts`](open-sse/observability/comboSpan.ts),
[`src/instrumentation-node.ts`](src/instrumentation-node.ts).

---

Expand Down
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
| Hand-rolled Rust | rejected (deferred to v9) | 6+ months of dev to match Bifrost's feature parity. Only worth it if Bifrost is abandoned upstream. |
| Hand-rolled Zig/Mojo | rejected | Mojo too immature (alpha); Zig is a systems language with no ecosystem for HTTP/JSON providers. Not justified. |

### 2.5.2 v8.1 Task Track (B1–B9)
### 2.5.2 v8.1 Task Track (B1–B10)

| ID | Task | Owner | Effort | Status |
|---|---|---|---|---|
Expand All @@ -142,6 +142,7 @@
| **B8** | Bifrost MCP client integration (use Bifrost as upstream MCP source for OmniRoute's MCP-router) | mcp | M | ✅ PR #93 OPEN 2026-06-19 |
| **B9** | Kill switch: keep OmniRoute's `open-sse/` engine as fallback if Bifrost fails SLOs for 7 days | core | S | ✅ PR #95 OPEN 2026-06-20 |
| **B9.1** | Wire kill switch into `BifrostBackendExecutor` (pre-check `isActive`, post `recordObservation`, healthCheck propagation, `BIFROST_KILLSWITCH_DISABLED` env-bypass) | core | S | ✅ DONE 2026-06-20 |
| **B10** | **OTel bridge — unified traces Tier-1 (Bifrost, Go) ⇄ Tier-2 (OmniRoute, TS) via W3C `traceparent`** | observability | M | ✅ DONE 2026-06-21 |

### 2.5.3 Decision review schedule

Expand Down
6 changes: 6 additions & 0 deletions config/quality/dependency-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
"@monaco-editor/react",
"@ngrok/ngrok",
"@opencode-ai/plugin",
"@opentelemetry/api",
"@opentelemetry/exporter-trace-otlp-http",
"@opentelemetry/resources",
"@opentelemetry/sdk-node",
"@opentelemetry/sdk-trace-base",
"@opentelemetry/semantic-conventions",
"@playwright/test",
"@size-limit/file",
"@stryker-mutator/core",
Expand Down
31 changes: 24 additions & 7 deletions open-sse/executors/bifrost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
BifrostKillSwitchActiveError,
BIFROST_KILLSWITCH_ACTIVE,
} from "../services/bifrostKillSwitch.ts";
import { withBifrostSpan } from "../observability/bifrostSpan.ts";

const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8080;
Expand Down Expand Up @@ -248,16 +249,32 @@ export class BifrostBackendExecutor extends BaseExecutor {
// We measure latency around the fetch and always record an
// observation. `ok` is true on 2xx and false on any other status or
// thrown error. The kill switch uses these to auto-trip when
// thresholds (p99 latency, error rate, cost ratio) are exceeded.
// thresholds (p99 latency, error rate, cost ratio) are exceeded. The
// fetch itself is wrapped in a Bifrost OTel span (B10) so Tier-1/Tier-2
// traces stay unified via the injected `traceparent`.
const startTime = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: combinedSignal,
});
const { result } = await withBifrostSpan(
{
provider: this.provider,
bifrostProvider: bifrostProviderId,
model,
baseUrl,
headers,
},
async (span) => {
const upstreamResponse = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: combinedSignal,
});
span.setAttribute("http.status_code", upstreamResponse.status);
return upstreamResponse;
}
);
response = result;
} catch (err) {
// Fetch threw (network error, abort, timeout). Record a failed
// observation and re-throw. The dispatcher will handle the error.
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare const EdgeRuntime: string | undefined;
*/

import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { generateTraceparent } from "../observability/traceparent.ts";
import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts";
import {
buildAgentRequestBody,
Expand Down Expand Up @@ -692,7 +693,7 @@ export class CursorExecutor extends BaseExecutor {
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
const requestId = crypto.randomUUID();
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
const traceParent = generateTraceparent({ sampled: true });

// Mirrors cursor-agent's actual headers for agent.v1.AgentService/Run.
// Notably: no x-cursor-checksum, no machineId, no x-amzn-trace-id.
Expand Down
14 changes: 2 additions & 12 deletions open-sse/executors/grok-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type TlsFetchResult,
} from "../services/grokTlsClient.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { generateTraceparent } from "../observability/traceparent.ts";

// ─── Constants ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -81,14 +82,6 @@ function generateStatsigId(): string {
return btoa(msg);
}

// ─── Helpers ────────────────────────────────────────────────────────────────

function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}

// ─── OpenAI message → Grok query translation ───────────────────────────────

interface OpenAIToolCall {
Expand Down Expand Up @@ -1722,9 +1715,6 @@ export class GrokWebExecutor extends BaseExecutor {
};

// Build headers
const traceId = randomHex(16);
const spanId = randomHex(8);

const headers: Record<string, string> = {
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br, zstd",
Expand All @@ -1745,7 +1735,7 @@ export class GrokWebExecutor extends BaseExecutor {
"User-Agent": GROK_USER_AGENT,
"x-statsig-id": generateStatsigId(),
"x-xai-request-id": crypto.randomUUID(),
traceparent: `00-${traceId}-${spanId}-00`,
traceparent: generateTraceparent({ sampled: false }),
};

// Cookie auth — accepts a bare value, "sso=<value>", or a full DevTools
Expand Down
Loading
Loading