Skip to main content

Overview

Laminar is an open-source, OpenTelemetry-native observability platform for Temporal workflows. Trace, debug, and monitor a workflow run as a single trace, even though its client, workflow, and activities run in separate processes. Self-host via Helm or use managed cloud. Temporal splits an application across processes: a client starts workflows, a worker runs the workflow functions and their activities, and workflows can fan out to activities, child workflows, signals, and updates. Laminar propagates trace context through Temporal headers, so the client-side workflow call, the worker-side workflow, and every activity it schedules land under one trace instead of scattering into disconnected spans. In Python this is fully automatic; in TypeScript you register a set of interceptors. What Laminar captures:
  • A client-side span for each workflow’s lifecycle (start to result, cancel, or terminate).
  • A span per activity execution on the worker, named after the activity type, with arguments and return value.
  • Any LLM calls, tool calls, or manual spans inside an activity, nested under that activity.
  • Trace context forwarded to activities, child workflows, and continueAsNew scheduled from inside a workflow.

How the wiring fits together

Temporal runs the client and worker as separate entry points. How much you wire up depends on the language.
You initialize Laminar and register interceptors in each process:
  • Client process: initialize Laminar with the @temporalio/client module so workflow runs are wrapped in a span, then register the workflow, activity, and schedule client interceptors. These inject the active trace context into Temporal headers.
  • Worker process: initialize Laminar with disableBatch: true, register the activity interceptor (reads the headers and wraps each activity in a span), and register the workflow interceptor module (forwards the trace context from a running workflow to anything it schedules).
The workflow interceptor is special: Temporal compiles workflow code into a deterministic V8 sandbox, so the interceptor has to be a standalone module that Temporal bundles separately. You register it by path, not by passing an instance.

Getting started

1

Install

Ensure you are using @lmnr-ai/lmnr version 0.8.28 or higher:
npm install @lmnr-ai/lmnr@latest @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity ai @ai-sdk/openai
# or
pnpm add @lmnr-ai/lmnr@latest @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity ai @ai-sdk/openai
The ai and @ai-sdk/openai packages are only for the example activity below; swap them for whatever your activities actually call.
2

Set environment variables

export LMNR_PROJECT_API_KEY=your-laminar-project-api-key
3

Define the workflow interceptor module

The Laminar SDK ships a ready-made workflow interceptor entry point. Reference it by path with require.resolve so Temporal can bundle it into the workflow sandbox:
require.resolve('@lmnr-ai/lmnr/temporal-workflow-interceptors')
You pass this path in the worker setup below. If your worker uses a pre-built workflow bundle, or you prefer to keep the interceptor in your own repository, see Keep the workflow interceptor in your own file.
4

Set up the worker

Initialize Laminar with disableBatch: true, register the activity interceptor factory, and register the workflow interceptor module by path:
worker.ts
import { Worker } from '@temporalio/worker';
import { Laminar, LaminarTemporalInterceptors } from '@lmnr-ai/lmnr';
import * as activities from './activities';

Laminar.initialize({
  disableBatch: true,
});

async function run() {
  const worker = await Worker.create({
    workflowsPath: require.resolve('./workflows'),
    activities,
    taskQueue: 'summary-queue',
    interceptors: {
      activity: [LaminarTemporalInterceptors.ActivityInterceptorFactory()],
      workflowModules: [
        require.resolve('@lmnr-ai/lmnr/temporal-workflow-interceptors'),
      ],
    },
  });
  await worker.run();
}

run().catch((err) => {
  console.error(err);
  process.exit(1);
});
Set disableBatch: true in the worker. The worker spawns short-lived activity executions, and with the default batching span processor the internal spans created inside an activity can be dropped before they are flushed.
5

Set up the client

Import the @temporalio/client module as a namespace and pass it to Laminar.initialize so each workflow run is wrapped in a span. Then register the workflow, activity, and schedule client interceptors:
client.ts
import { Connection, Client } from '@temporalio/client';
import * as client from '@temporalio/client';
import { Laminar, LaminarTemporalInterceptors, observe } from '@lmnr-ai/lmnr';
import { summaryWorkflow } from './workflows';

Laminar.initialize({
  instrumentModules: { temporal: { client } },
  disableBatch: true,
});

async function run() {
  const connection = await Connection.connect();
  const temporalClient = new Client({
    connection,
    interceptors: {
      workflow: [new LaminarTemporalInterceptors.WorkflowClientInterceptor()],
      activity: [new LaminarTemporalInterceptors.ActivityClientInterceptor()],
      schedule: [new LaminarTemporalInterceptors.ScheduleClientInterceptor()],
    },
  });

  await observe({ name: 'run-summary' }, async () => {
    const result = await temporalClient.workflow.execute(summaryWorkflow, {
      taskQueue: 'summary-queue',
      workflowId: `summary-${Date.now()}`,
      args: ['distributed tracing for Temporal workflows'],
    });
    console.log('Workflow result:', result);
  });

  await Laminar.shutdown();
  await connection.close();
}

run().catch((err) => {
  console.error(err);
  process.exit(1);
});
Wrapping the execute call in observe() is optional but recommended: it creates a root span so the whole run is easy to find in the UI. Without it, the workflow lifecycle span becomes the root.
Pass the @temporalio/client module via instrumentModules: { temporal: { client } }. This is what lets Laminar wrap each workflow run (start to result) in a span, which is the parent that the worker-side workflow and activities nest under. The three client interceptors then inject that span’s context into Temporal headers.
6

Write workflows and activities as usual

Activities run on the worker as ordinary functions, so any auto-instrumented SDK call or observe() span inside them is captured and nested under the activity span:
activities.ts
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { observe, getTracer } from '@lmnr-ai/lmnr';

export async function summarizeTopic(topic: string): Promise<string> {
  const { text } = await generateText({
    model: openai('gpt-5-mini'),
    system: 'You are a concise technical writer.',
    prompt: `Write a two-sentence summary of: ${topic}`,
    experimental_telemetry: {
      isEnabled: true,
      tracer: getTracer(),
    },
  });
  return text;
}

export async function countWords(text: string): Promise<number> {
  return observe({ name: 'count_words', spanType: 'TOOL', input: { text } }, () =>
    text.trim().split(/\s+/).filter(Boolean).length,
  );
}
workflows.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { summarizeTopic, countWords } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export async function summaryWorkflow(topic: string): Promise<{
  summary: string;
  wordCount: number;
}> {
  const summary = await summarizeTopic(topic);
  const wordCount = await countWords(summary);
  return { summary, wordCount };
}
Do not call observe() or Laminar.startSpan() inside a workflow function. Workflow code runs in Temporal’s deterministic V8 sandbox, where crypto.randomUUID() and real timestamps are unavailable, so span creation throws. Instrument inside activities (which run as normal Node functions) and on the client; the workflow interceptor handles context propagation for you.

See what happened in a trace

Open the trace in Laminar and you land on the transcript view: the workflow’s LLM call reads as a conversation, with the prompt, the model’s response, and the count_words tool call inline. A span tree tells you the shape of the run; the transcript tells you what actually happened.
Temporal workflow trace in Laminar, transcript view
Production workflows rarely run two neat activities back to back. They fan out across many activities, child workflows, and signals, each in its own process, and a naive setup scatters them into a pile of disconnected traces you have to correlate by hand. Laminar does that correlation for you: it propagates the trace context through Temporal headers so the client-side run-summary root, the workflow lifecycle span, every activity, and the LLM and tool spans inside them all land in one trace, out of the box, no matter how disjoint the activities are. The tree view below just makes that single stitched-together hierarchy easy to read.
Temporal workflow trace in Laminar, tree view showing cross-process span nesting
More on the trace UX: Viewing traces.

Control what activity spans record (TypeScript)

In TypeScript, ActivityInterceptorFactory accepts three options, all defaulting to true:
interceptors: {
  activity: [
    LaminarTemporalInterceptors.ActivityInterceptorFactory({
      createActivitySpan: true,
      recordActivityArgs: true,
      recordActivityOutput: true,
    }),
  ],
}
  • createActivitySpan: wrap each activity execution in a span named after the activity type. Set to false to only restore the trace context (so your own observe() calls inside the activity act as roots) without an extra wrapper span.
  • recordActivityArgs: record the activity’s arguments as the span input. Set to false to omit large or sensitive arguments.
  • recordActivityOutput: record the activity’s return value as the span output. Set to false to omit large or sensitive results.
recordActivityArgs and recordActivityOutput are ignored when createActivitySpan is false. The three client interceptors map to Temporal’s three client surfaces:
  • WorkflowClientInterceptor: injects trace context on workflow start, signal, query, update, terminate, and describe calls.
  • ActivityClientInterceptor: injects trace context on standalone activity client calls. Standalone activities are experimental in Temporal, so this interceptor is best-effort.
  • ScheduleClientInterceptor: registered but a deliberate no-op. A Schedule is a long-lived server-side object whose stored workflow-start action replays on every triggered run, so injecting the active span at creation time would pin every future run to one long-dead trace. Each triggered run starts its own root trace instead.

Keep the workflow interceptor in your own file (TypeScript)

The require.resolve('@lmnr-ai/lmnr/temporal-workflow-interceptors') path works for most setups. Two cases call for vendoring the interceptor in your own repository and passing a relative path:
  • Your bundler or monorepo layout cannot resolve the package entry point inside the workflow bundle.
  • You build workflows with a pre-built bundle via bundleWorkflowCode (see the note below).
Copy the file below into your project (for example src/laminar-workflow-interceptors.ts) and reference it the same way: require.resolve('./laminar-workflow-interceptors').
laminar-workflow-interceptors.ts
import {
  AsyncLocalStorage,
  type Headers,
  type WorkflowInterceptors,
} from '@temporalio/workflow';

const LAMINAR_SPAN_CONTEXT_HEADER = 'x-lmnr-span-context';
const TRACEPARENT_HEADER = 'traceparent';

const hasTraceHeaders = (headers: Headers): boolean =>
  headers[LAMINAR_SPAN_CONTEXT_HEADER] != null ||
  headers[TRACEPARENT_HEADER] != null;

let _startHeaders: Headers = {};
const _handlerHeaders = new AsyncLocalStorage<Headers>();
const activeHeaders = (): Headers =>
  _handlerHeaders.getStore() ?? _startHeaders;

export const interceptors = (): WorkflowInterceptors => ({
  inbound: [
    {
      execute: async (input, next) => {
        _startHeaders = input.headers ?? {};
        return next(input);
      },
      handleSignal: async (input, next) => {
        const headers = input.headers ?? {};
        return hasTraceHeaders(headers)
          ? _handlerHeaders.run(headers, () => next(input))
          : next(input);
      },
      handleUpdate: async (input, next) => {
        const headers = input.headers ?? {};
        return hasTraceHeaders(headers)
          ? _handlerHeaders.run(headers, () => next(input))
          : next(input);
      },
    },
  ],
  outbound: [
    {
      scheduleActivity: async (input, next) =>
        next({ ...input, headers: { ...activeHeaders(), ...input.headers } }),
      scheduleLocalActivity: async (input, next) =>
        next({ ...input, headers: { ...activeHeaders(), ...input.headers } }),
      startChildWorkflowExecution: async (input, next) =>
        next({ ...input, headers: { ...activeHeaders(), ...input.headers } }),
      continueAsNew: async (input, next) =>
        next({ ...input, headers: { ...activeHeaders(), ...input.headers } }),
    },
  ],
});
If you create the worker with a pre-built workflowBundle, Temporal ignores interceptors.workflowModules, because the bundle is already compiled. Register the interceptor at bundle time instead:
await bundleWorkflowCode({
  workflowsPath: require.resolve('./workflows'),
  workflowInterceptorModules: [
    require.resolve('@lmnr-ai/lmnr/temporal-workflow-interceptors'),
  ],
});

Track outcomes with Signals

Traces answer what happened on this run. Signals answer the cross-trace question: how often does an activity retry before succeeding, which workflows exceed their timeout, when does the model return an empty summary. A Signal pairs a plain-language prompt with a JSON output schema. Laminar runs it live on new traces (Triggers) or backfills it across history (Jobs) and records a structured event every time it matches. From there you query, cluster, and alert on events across every trace.
Every new project ships with a Failure Detector Signal that categorizes issues on any trace over 1000 tokens. Open it from the Signals sidebar to see events as soon as your workflow traces arrive.

Query across traces

  • SQL editor for ad-hoc queries across traces, spans, signals, and evals.
  • SQL API for programmatic access from scripts and pipelines.
  • CLI (lmnr-cli sql query) for terminal-driven queries and piping JSON into shell tools or coding agents.
  • MCP server to query Laminar directly from Claude Code, Cursor, Codex, or any MCP-aware client.

Troubleshooting

  • Confirm LMNR_PROJECT_API_KEY is set in both the client and the worker process.
  • Python: both processes must call Laminar.initialize() before the Client connects, and temporalio must be importable at that point so the auto-instrumentation engages.
  • TypeScript: both processes must call Laminar.initialize(). The client needs instrumentModules: { temporal: { client } }; the worker needs disableBatch: true.
  • Python: make sure Laminar.initialize() runs before Client.connect() in both processes. The interceptor that propagates context is injected at Client construction, so initializing afterward misses it.
  • TypeScript: the client must register WorkflowClientInterceptor and pass the @temporalio/client module via instrumentModules: { temporal: { client } }. The module is what wraps the run in the parent span; the interceptor injects that span into headers. The worker must register the workflow interceptor module via interceptors.workflowModules. If it is missing, the workflow starts but never forwards its context to the activities it schedules.
  • Python: confirm Laminar.initialize(disable_batch=True) runs in the worker process before the worker connects. Without disable_batch=True, short-lived activity executions can exit before the batching span processor flushes their spans.
  • TypeScript: register ActivityInterceptorFactory() under interceptors.activity in Worker.create, and set disableBatch: true in the worker. Short-lived activity executions can exit before a batched span processor flushes their spans. If you set createActivitySpan: false, activities only restore context and rely on your own observe() spans to appear.
You are creating a span inside a workflow function (Laminar.start_as_current_span() / Laminar.start_span() in Python, observe() / Laminar.startSpan() in TypeScript). Workflow code runs in Temporal’s deterministic sandbox and cannot create spans. Move instrumentation into activities (normal functions) or onto the client.
Set the base URL and the ports of your instance when initializing. For a local OSS deployment:
Laminar.initialize({
  baseUrl: 'http://localhost',
  httpPort: 8000,
  grpcPort: 8001,
  disableBatch: true,
});
Laminar.initialize(
    base_url="http://localhost",
    http_port=8000,
    grpc_port=8001,
    disable_batch=True,
)

What’s next

Viewing traces

Read the transcript view, filter, and search across traces.

Signals

Detect behaviors and failures across every run, then query, cluster, and alert on them.

SQL editor and MCP server

Query traces programmatically from the UI, API, or your IDE.

Span types

Mark functions as TOOL or LLM spans so they surface in the transcript view.