TypeScript SDK
Set up Harbor tracing in a Node.js agent and stream its spans over OTLP.
harbor-ai-sdk registers an OpenTelemetry tracer provider, batches the spans your agent
produces and exports them to Harbor over OTLP/HTTP. Spans are sent unmodified — Harbor
normalizes them server-side — so the SDK stays small and never lags behind the ingestion
pipeline.
If you build on the Vercel AI SDK or Mastra, start on those pages. This one covers the client itself, adding context to traces, and tracing code no framework knows about.
Install
npm install harbor-ai-sdkNode.js 20 or later.
Initialize
Create the client once, as early in the process as you can.
import { Harbor } from 'harbor-ai-sdk'
export const harbor = new Harbor()
await harbor.readyimport { harbor } from './telemetry'new Harbor() registers the global tracer provider, so every span created afterwards — by
your code or by any library writing to OpenTelemetry — is exported to Harbor.
apiKey defaults to HARBOR_API_KEY and baseUrl to HARBOR_BASE_URL. Pass either
explicitly if you load configuration some other way. With either missing the client warns
once, harbor.enabled is false, and nothing is exported.
Setup is synchronous today, so await harbor.ready is a formality — it is there so your
code stays correct if that changes.
Group a turn with capture
capture opens one root span for a unit of work and attaches context to every span
created inside it, at any depth, including spans your framework created. This is how a trace
gets a user, a session and your own metadata.
import { capture } from 'harbor-ai-sdk'
app.post('/chat', async (req, res) => {
const answer = await capture('chat-turn', () => runAgent(req.body.message), {
userId: req.user.id,
sessionId: req.body.conversationId,
tags: ['production', 'v2-prompt'],
metadata: { requestId: req.id, plan: req.user.plan },
})
res.json({ answer })
})Prop
Type
Wrap the request handler, the job, or the agent entrypoint once — not every internal step.
A nested capture joins the current trace rather than starting a second root: scalars are
overridden by the innermost call, metadata is shallow-merged, and tags accumulate.
Pass the same sessionId across turns of one conversation and Harbor stitches them into a
session, which is the unit trajectory scoring runs on.
The traced work must happen inside the callback. For a streaming response, create and consume the stream there — once the callback returns, the context is no longer active and later spans lose it.
For control flow a callback cannot wrap — middleware where start and end are separate hooks
— use the lifecycle form and end every scope you start. Spans created outside scope.run()
are not children of the capture.
const scope = capture.start('request', { userId: user.id })
try {
await scope.run(() => handle(request))
} catch (error) {
capture.end(scope, error)
throw error
}
capture.end(scope)Trace your own code
harbor.tracer is a plain OpenTelemetry Tracer, so there is no Harbor-specific span API to
learn. Spans you start inherit the enclosing capture context like any other.
import { harbor } from './telemetry'
const plan = await harbor.tracer.startActiveSpan('plan-steps', async (span) => {
const steps = await buildPlan(question)
span.setAttributes({
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.output.messages': JSON.stringify(steps),
})
span.end()
return steps
})OpenTelemetry attribute values must be scalars or arrays of scalars, so anything structured has to be a JSON string. A span that never ends is never exported.
Auto-instrumentation
Pass any OpenTelemetry instrumentation and the client registers it. Because an
instrumentation patches a library when that library is loaded, new Harbor() has to run
before the module it wraps — which is why the setup belongs in its own module, imported
first.
import { OpenAIInstrumentation } from '@arizeai/openinference-instrumentation-openai'
import { Harbor } from 'harbor-ai-sdk'
export const harbor = new Harbor({
instrumentations: [new OpenAIInstrumentation()],
})Harbor reads whatever conventions the instrumentation emits, so any package following the GenAI, OpenInference or OpenLLMetry attribute names works without an adapter here.
Flush before the process exits
Spans are batched. Long-running servers need nothing extra, but scripts, jobs, tests and serverless handlers must flush or the last batch dies with the process.
try {
await runAgent()
} finally {
await harbor.flush()
}flush() never throws, so it is safe in a finally — a failed export is logged, not raised
into the error you were already handling. The client also flushes on beforeExit, which
covers a script running out of work but not process.exit() or a signal; call
harbor.shutdown() on those paths.
Options
| Option | Default | Description |
|---|---|---|
apiKey | process.env.HARBOR_API_KEY | Identifies your organization and project |
baseUrl | process.env.HARBOR_BASE_URL | Spans are posted to <baseUrl>/v1/traces |
instrumentations | [] | OpenTelemetry instrumentations to register |
| Member | Description |
|---|---|
harbor.ready | Resolves once tracing is wired up |
harbor.enabled | false when the key or base URL is missing |
harbor.tracer | OpenTelemetry Tracer for your own spans |
harbor.flush() | Export everything buffered now |
harbor.shutdown() | Flush, then tear down. Idempotent |
Bring your own OpenTelemetry
If something else already owns the tracer provider — your own NodeSDK, or an APM agent —
do not construct Harbor; OpenTelemetry allows one provider and the second registration
loses. Add Harbor's span processor to that setup instead, and your spans go to both backends.
import { NodeSDK } from '@opentelemetry/sdk-node'
import { HarborSpanProcessor } from 'harbor-ai-sdk'
export const harborSpanProcessor = new HarborSpanProcessor()
const sdk = new NodeSDK({
spanProcessors: [harborSpanProcessor],
})
sdk.start()HarborSpanProcessor takes the same apiKey and baseUrl options, applies capture
context, and exposes forceFlush(). Export it so you can flush where you need to.
Troubleshooting
Check the key and base URL exist in the runtime that actually runs your code — the SDK
logs Missing apiKey ... Tracing is disabled once at startup when they do not. Then check
you flushed before exit. 401 means the key is missing or revoked; 404 means the URL
does not resolve to /v1/traces.
The traced call ran outside the capture callback, or a stream was consumed after the
callback returned. Context is attached when a span starts, so a span that starts outside
the scope never gets it.
Nesting comes from context propagation. Either the child span was created outside the
parent's callback, or another library replaced the global context manager. new Harbor()
installs AsyncLocalStorageContextManager when it owns the provider; in a
bring-your-own-OpenTelemetry setup that is your responsibility.
The SDK forwards whatever attributes were set. Check the raw attributes on the span in
Harbor — the key is probably not one of the conventions Harbor reads, and setting the
equivalent gen_ai.* key fixes it.
Another OpenTelemetry provider was registered first, so new Harbor() silently lost the
global. Use HarborSpanProcessor on the existing provider instead.