Express + OpenAI

30 lines. Gates on a server-evaluated policy, runs an auto-instrumented LLM call, writes a hash-chained attestation. The same code as the Quickstart — this page walks through what each block does.


Install + env

Both covered in the Quickstart — the short version is npm i @avanor/sdk @avanor/sdk-express @avanor/sdk-openai plus exporting AVANOR_API_KEY and OPENAI_API_KEY before node.

The full file

app.tsts
import express from 'express';
import { Avanor } from '@avanor/sdk';
import { instrumentOpenAI } from '@avanor/sdk-openai';
import OpenAI from 'openai';

const avanor = Avanor.init({
  apiKey: process.env.AVANOR_API_KEY!,
  environment: process.env.NODE_ENV ?? 'dev',
  service: 'broker-portal',
});
const openai = instrumentOpenAI(new OpenAI()); // monkey-patches in place

const app = express();
app.use(express.json());
app.use(avanor.middleware());                  // tracks every request

app.post('/send-email', async (req, res) => {
  const { to, subject, body } = req.body;

  const { allow, reason, approvalId } = await avanor.allow('email_send', {
    'recipient.domain': to.split('@')[1],
    'subject.length': subject.length,
    'has_pii': /\b\d{3}-\d{2}-\d{4}\b/.test(body),
  });
  if (!allow) return res.status(403).json({ reason, approvalId });

  const draft = await openai.chat.completions.create({           // auto-instrumented
    model: 'gpt-4o',
    messages: [{ role: 'user', content: `Rewrite professionally: ${body}` }],
  });

  // ... send via Resend/SES ...

  const att = await avanor.attest('email_send', {
    input: { to, subject }, output: { messageId: 'abc123' }, status: 'ok',
  });
  res.json({ ok: true, attestId: att.attestId });
});

app.listen(3000);

Line-by-line

Imports

ts
import express from 'express';
import { Avanor } from '@avanor/sdk';
import { instrumentOpenAI } from '@avanor/sdk-openai';
import OpenAI from 'openai';

Three Avanor surfaces: @avanor/sdk for the core client, @avanor/sdk-openai for the OpenAI auto-instrumentation factory, and express + openai as your normal application dependencies.

Init + instrument

ts
const avanor = Avanor.init({
  apiKey: process.env.AVANOR_API_KEY!,
  environment: process.env.NODE_ENV ?? 'dev',
  service: 'broker-portal',
});
const openai = instrumentOpenAI(new OpenAI()); // monkey-patches in place

Avanor.init() is idempotent and returns the same client on every call — safe at any entry point. The instrumentOpenAI() call monkey-patches the OpenAI client in place, returning the SAME instance back to you for chaining. Every subsequent openai.chat.completions.create() call now wraps in an llm.call span with gen_ai.provider.name, gen_ai.request.model, and token usage attributes populated from the response.

Express middleware

ts
const app = express();
app.use(express.json());
app.use(avanor.middleware());                  // tracks every request

avanor.middleware() returns an Express-shaped request handler that opens a root span per request. Every track(), allow(), attest(), and auto-instrumented call inside the handler hangs off that root span via AsyncLocalStorage. No manual context plumbing.

Pre-action gate — allow()

ts
const { allow, reason, approvalId } = await avanor.allow('email_send', {
  'recipient.domain': to.split('@')[1],
  'subject.length': subject.length,
  'has_pii': /\b\d{3}-\d{2}-\d{4}\b/.test(body),
});
if (!allow) return res.status(403).json({ reason, approvalId });

The SDK makes an HTTPS call to Avanor's /v1/runtime/allowendpoint with your Bearer token. Server-side, the request is routed through the tenant's engine-aware governance evaluator (JSON Logic + OPA Rego). If a tenant-authored policy matches the action, you get a real { allow, reason, policyId } back. If no rule matches, you get { allow: true, reason: 'no_policy_for_action' } (the call ran, no rule blocked it). The default failure mode is fail-soft: on network or 5xx errors the SDK returns allow: true and logs a warning — your product never stops because Avanor is unreachable. Override per-client with failureMode: 'fail-closed' when you want denial-on-doubt semantics.

Auto-instrumented OpenAI call

ts
const draft = await openai.chat.completions.create({           // auto-instrumented
  model: 'gpt-4o',
  messages: [{ role: 'user', content: `Rewrite professionally: ${body}` }],
});

Your code is unchanged from a vanilla OpenAI call. The wrapped method opens a child span under the request's root, sets gen_ai.operation.name = chat.completions.create, copies the model arg into gen_ai.request.model, and after the response lands reads response.usage into gen_ai.usage.input_tokens and gen_ai.usage.output_tokens. The return value is the original OpenAI response, unchanged — observation only.

Post-action attestation — attest()

ts
const att = await avanor.attest('email_send', {
  input: { to, subject }, output: { messageId: 'abc123' }, status: 'ok',
});
res.json({ ok: true, attestId: att.attestId });

attest() POSTs to /v1/runtime/attestwhich writes a hash-chained row into the customer's audit_logs table. You get back { attestId, hashChainPosition, sha256 } — the SHA-256 is computed server-side over (previous_hash, action, input, output, status, timestamp) so a tampered row breaks the chain immediately on verifyChain(). Hand the attestId back to the caller (or store it in your own DB) so the action and its evidence row stay linkable forever.


What you don't have to think about

  • Tenant binding. The API key is the entire identity. The SDK never accepts a tenant_idfield — it's derived server-side from the Bearer token.
  • Span context. The middleware runs your handler inside an AsyncLocalStorage scope; every nested call (the OpenAI call, the allow(), the attest()) auto-attaches to the request span.
  • Retry + backoff. Failed ingest POSTs retry with exponential backoff and jitter (1s → 2s → 4s → 8s → 16s, max 5 attempts). The queue is bounded; on overflow the oldest events are dropped and a counter is incremented.
  • Kill switch. Avanor support can flip your installation to no-op mode from /dashboard/sdk-control without you redeploying. The SDK long-polls every 60s; an sdk_killed event lands in your audit log before the transition so the kill is never silent.

Next