DEVELOPER DOCS

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 returns 415 from 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 BatchSpanProcessor scheduled delay.