SDK
TypeScript / JavaScript
On this page
There is no Evigauge npm package. TypeScript instruments through native
OpenTelemetry JS, exporting OTLP/JSON, with a small helper module that sets the
opexia.* attributes correctly.
Installation
Shell
npm i @opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions \
@opentelemetry/api
Use
@opentelemetry/exporter-trace-otlp-http— the http/json exporter. The protobuf exporter returns415from Evigauge ingest.
Bootstrap
Import this module first, before anything you want traced — via
node -r ./otel.js or as the very first import in your entrypoint.
TypeScript
// otel.ts
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
const base = (process.env.OPEXIA_INGEST_URL ?? "https://ingest.opexia.dev")
.replace(/\/$/, "");
const exporter = new OTLPTraceExporter({
url: `${base}/v1/traces`,
headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY ?? "" },
});
const provider = new NodeTracerProvider({
resource: resourceFromAttributes({
// service.name falls back to opexia.project_id at ingest.
[ATTR_SERVICE_NAME]: process.env.OPEXIA_PROJECT_ID || "default",
}),
spanProcessors: [new BatchSpanProcessor(exporter)],
});
provider.register();
// Flush on shutdown so the last batch is not lost.
process.on("SIGTERM", () => provider.shutdown().catch(() => {}));
Attribute helpers
TypeScript
// opexia-attributes.ts
import { trace, Span, SpanStatusCode, Tracer } from "@opentelemetry/api";
// Server-validated enums — anything else fails validation.
export type ReasoningRole =
| "decomposer" | "research" | "analysis" | "critique"
| "synthesis" | "arbiter" | "retrieval" | "guardrail" | "post_process";
export type NodeType =
| "decomposer" | "classifier" | "agent" | "guardrail"
| "post_process" | "retrieval";
export interface OpexiaEnvelope {
orgId: string;
workspaceId: string; // workspace UUID the dashboard reads
projectId: string;
userId?: string;
}
/** Required tenancy envelope — set on EVERY span. */
export function setOpexiaEnvelope(span: Span, env: OpexiaEnvelope): void {
span.setAttribute("opexia.schema_version", "1.0");
span.setAttribute("opexia.org_id", env.orgId);
span.setAttribute("opexia.workspace_id", env.workspaceId);
span.setAttribute("opexia.project_id", env.projectId);
if (env.userId) span.setAttribute("opexia.user_id", env.userId);
// opexia.trace_id must equal the OTel trace id.
span.setAttribute("opexia.trace_id", span.spanContext().traceId);
}
export interface OpexiaSources {
consulted?: string[]; // string ids/URLs, NOT objects
used?: string[]; // subset actually cited
dropped?: string[];
scores?: Record<string, number>; // 0..1 per id
}
/** opexia.sources as a JSON STRING with ONLY the 4 allowed keys. */
export function setOpexiaSources(span: Span, src: OpexiaSources): void {
span.setAttribute("opexia.sources", JSON.stringify({
consulted: src.consulted ?? [],
used: src.used ?? [],
dropped: src.dropped ?? [],
scores: src.scores ?? {},
}));
}
export interface OpexiaDecision {
selected?: string;
rulesFired?: string[];
scores?: Record<string, number>;
alternatives?: Record<string, unknown>[];
}
/** opexia.decision as a JSON STRING with ONLY the 4 allowed keys. */
export function setOpexiaDecision(span: Span, dec: OpexiaDecision): void {
span.setAttribute("opexia.decision", JSON.stringify({
rules_fired: dec.rulesFired ?? [],
scores: dec.scores ?? {},
selected: dec.selected ?? null,
alternatives: dec.alternatives ?? [],
}));
}
export function setOpexiaReasoning(
span: Span,
opts: { role?: ReasoningRole; nodeType?: NodeType; parentReasoningId?: string },
): void {
if (opts.role) span.setAttribute("opexia.reasoning_role", opts.role);
if (opts.nodeType) span.setAttribute("opexia.node_type", opts.nodeType);
if (opts.parentReasoningId)
span.setAttribute("opexia.parent_reasoning_id", opts.parentReasoningId);
}
/** Evigauge truncates text at 16 KB; slicing here keeps sent == stored. */
const TEXT_CAP = 16384;
/**
* Query + final answer — PLAIN strings, never JSON.
* Put `query` on the EARLIEST span and `outcome` on the LATEST span of the trace.
*/
export function setOpexiaText(span: Span, t: { query?: string; outcome?: string }): void {
if (t.query) span.setAttribute("opexia.query_text", t.query.slice(0, TEXT_CAP));
if (t.outcome) span.setAttribute("opexia.outcome_text", t.outcome.slice(0, TEXT_CAP));
}
export interface ChatMessage { role: string; content: string }
/**
* Render an LLM call's messages into opexia.query_text.
*
* DO NOT join content with a space. Without a role boundary there is nothing
* separating the authored system prompt from per-call user content, so every
* call hashes as its own "prompt" and the Prompts page fills with thousands of
* one-call rows instead of one prompt with a version history.
*/
export function renderPrompt(messages: ChatMessage[]): string {
return messages.map(m => `${m.role ?? "user"}:\n${m.content ?? ""}`).join("\n\n");
}
/** Optional — pin prompt identity instead of letting Evigauge derive it. */
export function setOpexiaPrompt(
span: Span, p: { id?: string; label?: string; version?: string },
): void {
// Keys are FLAT: opexia.prompt_id, NOT opexia.prompt.id.
if (p.id) span.setAttribute("opexia.prompt_id", p.id);
if (p.label) span.setAttribute("opexia.prompt_label", p.label);
if (p.version) span.setAttribute("opexia.prompt_version", p.version);
}
/** gen_ai.* — standard OTel GenAI semconv. Cost is inferred server-side if omitted. */
export function setGenAiUsage(
span: Span,
u: {
system?: string; model?: string; operation?: string;
inputTokens?: number; outputTokens?: number; finishReason?: string;
costUsd?: number; pricingVersion?: string;
},
): void {
if (u.system) span.setAttribute("gen_ai.system", u.system);
if (u.model) span.setAttribute("gen_ai.request.model", u.model);
if (u.operation) span.setAttribute("gen_ai.operation.name", u.operation);
if (u.inputTokens != null) span.setAttribute("gen_ai.usage.input_tokens", u.inputTokens);
if (u.outputTokens != null) span.setAttribute("gen_ai.usage.output_tokens", u.outputTokens);
if (u.finishReason) span.setAttribute("gen_ai.response.finish_reason", u.finishReason);
if (u.costUsd != null) span.setAttribute("opexia.cost.usd", u.costUsd);
if (u.pricingVersion)
span.setAttribute("opexia.cost.model_pricing_version", u.pricingVersion);
}
// --- One-trace-per-request wrapper ------------------------------------------
let _tracer: Tracer | null = null;
let _env: OpexiaEnvelope | null = null;
export function initOpexia(env: OpexiaEnvelope, tracerName = "opexia"): void {
_env = env;
_tracer = trace.getTracer(tracerName);
}
/** Wrap one logical request (= one trace); the root span gets the envelope. */
export async function withOpexiaTrace<T>(
name: string,
fn: (span: Span) => Promise<T>,
opts?: { role?: ReasoningRole; nodeType?: NodeType },
): Promise<T> {
if (!_tracer || !_env) throw new Error("call initOpexia() first");
const tracer = _tracer, env = _env;
return tracer.startActiveSpan(name, async (span) => {
setOpexiaEnvelope(span, env);
if (opts) setOpexiaReasoning(span, opts);
try {
const out = await fn(span);
span.setStatus({ code: SpanStatusCode.OK });
return out;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
throw err;
} finally {
span.end();
}
});
}
Complete TypeScript example
TypeScript
// agent.ts — import ./otel first!
import "./otel";
import {
initOpexia, withOpexiaTrace, setOpexiaSources,
setOpexiaDecision, setOpexiaText, setGenAiUsage, renderPrompt,
} from "./opexia-attributes";
initOpexia({
orgId: process.env.OPEXIA_ORG_ID!,
workspaceId: process.env.OPEXIA_WORKSPACE_ID!, // UUID
projectId: "checkout-agent",
});
export async function handle(query: string): Promise<string> {
return withOpexiaTrace("handle_request", async (span) => {
// Query goes on the EARLIEST span.
setOpexiaText(span, { query });
const docs = [
"https://docs.internal/returns",
"https://docs.internal/shipping",
];
setOpexiaDecision(span, {
selected: "answer_from_kb",
scores: { answer_from_kb: 0.91, escalate: 0.12 },
rulesFired: ["kb_hit"],
});
setOpexiaSources(span, {
consulted: docs,
used: docs.slice(0, 1),
dropped: docs.slice(1),
});
const messages = [
{ role: "system", content: "You are a support agent." },
{ role: "user", content: query },
];
// On an LLM span, query_text IS the prompt — keep role boundaries.
setOpexiaText(span, { query: renderPrompt(messages) });
const answer = "Returns are accepted within 30 days.";
setGenAiUsage(span, {
system: "anthropic",
model: "claude-opus-4-6",
inputTokens: 2140,
outputTokens: 310,
});
// Outcome goes on the LATEST span.
setOpexiaText(span, { outcome: answer });
return answer;
}, { role: "synthesis", nodeType: "agent" });
}
Verifying a span landed (TypeScript)
TypeScript
// verify-span.ts
import "./otel";
import { initOpexia, withOpexiaTrace, setOpexiaText } from "./opexia-attributes";
import { trace } from "@opentelemetry/api";
const ORG = process.env.OPEXIA_ORG!; // slug
const WS = process.env.OPEXIA_WORKSPACE!; // slug
const KEY = process.env.OPEXIA_API_KEY!;
initOpexia({
orgId: ORG,
workspaceId: process.env.OPEXIA_WORKSPACE_ID!, // UUID
projectId: "verify",
});
const marker = `verify-${Math.random().toString(16).slice(2, 10)}`;
await withOpexiaTrace("verify_span", async (span) => {
setOpexiaText(span, { query: marker, outcome: "ok" });
}, { role: "analysis", nodeType: "agent" });
// Force the batch out, then let ingestion settle.
await (trace.getTracerProvider() as any).forceFlush?.();
console.log(`emitted ${marker}; waiting for ingestion…`);
await new Promise(r => setTimeout(r, 20_000));
const h = await (await fetch(
`https://api.opexia.dev/v1/observ/orgs/${ORG}/workspaces/${WS}/health/ingestion`,
{ headers: { "x-opexia-api-key": KEY } },
)).json();
console.log("dead-lettered last hour:", h.dead_letter_count_last_hour);
const t = await (await fetch(
`https://api.opexia.dev/v1/observ/orgs/${ORG}/workspaces/${WS}/traces?page_size=20&include_empty=true`,
{ headers: { "x-opexia-api-key": KEY } },
)).json();
// Payload is under "data", not "items".
if (!t.data.length) throw new Error("No traces — check key, workspace UUID, ingest URL.");
console.log("✅ span landed");
Next.js
Use instrumentation.ts at the project root — Next.js loads it before the
application.
TypeScript
// instrumentation.ts
export async function register() {
// Node runtime only — the edge runtime cannot use the Node OTel SDK.
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./otel");
}
}
JavaScript
// next.config.js — required on Next.js 14 and earlier
module.exports = { experimental: { instrumentationHook: true } };
Serverless functions can be frozen immediately after a response, killing the batch queue. On short-lived serverless, either flush explicitly before returning, or use a smaller
BatchSpanProcessorscheduled delay.