Laminar alternative: SQL over traces vs a prompt gateway
Laminar (lmnr) is a Y Combinator S24 project that calls itself observability purpose-built
for AI agents — a Rust ingest server, ClickHouse for spans, Quickwit for search, and an
OpenTelemetry SDK that instruments fifteen-plus agent frameworks from one line of code — Vercel
AI SDK, Claude Agent SDK, OpenAI Agents SDK, LangChain DeepAgents, Mastra, Pydantic AI, Browser
Use, Stagehand, Playwright, LiteLLM, OpenCode and OpenHands among them. It is
the most differently shaped tool we have compared AcruxCore against. Its project sidebar has
dashboards, traces, evaluations, datasets, labeling, a SQL editor, playgrounds, a debugger and
settings — and no prompt registry anywhere. That is not an oversight; it is the product
being about agent runs rather than about the calls inside them. Both projects are Apache 2.0,
so this is a comparison of shape, not of who is open.
Every paired screenshot below comes from the same prompt — vip-support-triage, a support
agent that changes tone for VIP customers and lists their open tickets — and the same customer
message. Both SDK runs end at the same downstream model, gpt-4o-mini on OpenAI, so the
generated text is comparable. Laminar's playground was configured with a direct OpenAI key
and run for real, so its screenshot below is a completion rather than an empty state. The
latency benchmark uses SDK paths on all three legs, every one of them ending at
api.openai.com. Licence, pricing,
team structure and community stats live on the compare page
rather than here.
At a glance
| Aspect | Laminar | AcruxCore | Winner |
|---|---|---|---|
| Tracing an agent run | OTel-native, nested spans, tree and transcript views, cost heatmap, 15+ framework integrations | Nested OTel spans on an OTLP receiver, five frameworks from one register() call, plus an automatic gateway span carrying prompt-version lineage | Laminar, on breadth and views |
| Where the platform sits | Beside the request path, on purpose — works with any provider, no proxy to configure | In the request path — routing, caching, budgets and virtual keys apply before the provider | Depends |
| Latency overhead, measured | −8 to +18 ms across three 100-round runs | +7 to +27 ms across the same three runs | Laminar, barely |
| Querying your trace data | Real SQL editor over spans, plus a drag-and-drop custom dashboard builder | Fixed analytics page, four group_by options, nothing composable | Laminar |
| Prompt authoring and versioning | None — a playground is one mutable row of messages, no versions, aliases or variables | Immutable versions, production/staging aliases, a diff tab, real {% if %} and {% for %} | AcruxCore |
| Sending a live call | Playground runs, but only against seven named providers, and reports tokens without cost | Any OpenAI-compatible provider, with cost, cache and latency shown inline | AcruxCore |
| How tools are handled | Tool schemas are a JSONB field on a playground row; tool calls show up as spans | Versioned Tool Catalog that the gateway actually executes, with its own analytics | AcruxCore |
| Evaluation and datasets | Code-first: your data, your executor, your scorers, run locally or in CI | Server-side runs, LLM judges, plus sampled rules that score live production spans | Depends |
| SDK and developer experience | One line auto-instruments your client; a CLI and skill onboard a coding agent | Fetch a stored prompt and send it through the gateway with no tracing code, or one register() call to instrument a framework | Depends |
Full breakdown, screenshots, and the verdict below.
Tracing an agent run
This is what Laminar is for, so it goes first. Laminar.initialize() patches the OpenAI client
through OpenTelemetry, and an @observe() decorator nests the model call underneath a parent
span — so the run's shape lands in the trace, not just the call.
from lmnr import Laminar, observe
Laminar.initialize(project_api_key=os.environ["LMNR_PROJECT_API_KEY"], base_url="http://localhost:8000")
@observe(name="vip_support_triage")
def triage(message: str) -> str:
return client.chat.completions.create(model="gpt-4o-mini", messages=[...]).choices[0].message.content
The span view carries the full system and user messages, token counts, cost, and an "Experiment in playground" button that lifts the call straight into their playground.

AcruxCore has two ways in. The gateway writes a span server-side, with no tracing code in the script at all, and links it back to the prompt version that produced it.

For the run's shape there is an OTLP/HTTP receiver — POST /api/v1/traces/otlp, protobuf or
JSON, gzip accepted — that takes nested OTel spans from your process, the same way Laminar's
does. Both SDKs collapse the provider, processor and exporter wiring into one register() call,
which can also switch on a framework's own OpenInference instrumentor:
from acruxcore.otel import register
register(api_key=KEY, base_url=BASE_URL, instrument=["crewai", "openai"])
Five frameworks are named today — OpenAI, OpenAI Agents SDK, CrewAI, LangChain and LlamaIndex —
and any other OTel or OpenInference instrumentor can be wired to the same provider by hand. A
CrewAI crew traced this way arrives as a real tree: a Crew.kickoff root, one agent span per
crew member, with tool and LLM spans nested underneath.

So the difference is breadth and reading, not whether the run is visible. Laminar ships three times the integrations and reads them back as a transcript, a cost heatmap and a realtime feed. We ship five named frameworks, a span tree with a latency waterfall, and prompt-version lineage on the gateway span — and unlike theirs, ours exists even when the caller sends no spans at all.
| Feature | Laminar | AcruxCore |
|---|---|---|
| Span source | Your process, via OpenTelemetry | The gateway server-side, or your process over OTLP |
| Code needed to get a trace | One initialize() call, plus decorators for structure | None for a gateway call; one register() for a framework |
| Nested agent structure | Yes, arbitrary depth | Yes, arbitrary depth, over the OTLP receiver |
| Prompt-version lineage on the span | No prompt registry to link to | Automatic |
| Views | Tree, transcript, cost heatmap, realtime | Span tree with a latency waterfall, payloads per span |
| Framework integrations | 15+, including Vercel AI SDK, Claude Agent SDK, Browser Use, Stagehand, Mastra, Pydantic AI | 5 via register(), plus any OTel instrumentor wired by hand |
Where the platform sits — in the request path, or beside it
Laminar sits beside the request path. Its Rust app-server receives spans; it never receives
your model call. We checked the server's routes: the only /chat/completions handling in the
codebase is its outbound client for its own AI features, not an inbound proxy.
That is a real design choice with real advantages. Any provider works immediately, there is no proxy to configure or keep up, and nothing Laminar does can fail your production call. The cost is that a budget, a cache hit or a virtual key has no call to act on — by the time Laminar sees the request, the money is already spent.
AcruxCore takes the other side. The call goes through us first, which is what makes a spend cap
enforceable: reserveBudgets() runs before the provider is contacted and returns
402 BUDGET_EXCEEDED, and a per-virtual-key RPM/TPM limit returns 429 RATE_LIMITED on the same
pre-call path. The cost is the hop, measured in the next section, and the fact that we have to
support your provider.
| Feature | Laminar | AcruxCore |
|---|---|---|
| Position | Beside the request path | In the request path |
| Works with any provider, unmodified | Yes | Only providers the gateway supports |
| Can enforce a spend cap before the call | No | Yes, 402 BUDGET_EXCEEDED |
| Can rate-limit a key before the call | No | Yes, 429 RATE_LIMITED, per virtual key |
| Can serve a cached response | No | Yes |
| A platform outage breaks your calls | No | Yes, unless you fail open |
Latency overhead — measured
100 rounds per run, interleaved in rotating order so a network blip lands on all three paths
equally, three warm-up rounds discarded, every leg ending at gpt-4o-mini on api.openai.com
with the same key and the same body. A gateway model is only a public name pointing at a
credential, so the script resolves ours before it starts and refuses to run unless it refers to a
native OpenAI credential — that way all three legs provably share one upstream.
A single run is not a result here: the median gap moves enough between runs that one number would be misleading. So it ran three times.
| Run | Direct-call median | Laminar gap | AcruxCore gap |
|---|---|---|---|
| 1 | 623 ms | −8 ms, CI [−27, +6] | +7 ms, CI [−12, +22] |
| 2 | 603 ms | +18 ms, CI [+2, +46] | +27 ms, CI [+11, +50] |
| 3 | 643 ms | −4 ms, CI [−28, +15] | +7 ms, CI [−19, +29] |
Laminar's overhead lands between −8 ms and +18 ms, AcruxCore's between +7 ms and +27 ms. In two of the three runs every confidence interval crosses zero, which means neither path is statistically distinguishable from calling the provider directly at this sample size. Laminar's median gap was the lower of the two in all three runs, by roughly 10 ms — consistent enough to be real, small enough that no application would notice it.
Both are cheap, and they are different kinds of cost: Laminar's is client-side instrumentation that batches spans off the hot path, ours is a real extra network hop that buys routing, budget enforcement, virtual keys and caching.
Full script:
latency_bench.py.
Querying your trace data
Laminar's SQL editor is the feature we most wish we had. It is not a filter builder — it is SQL over the ClickHouse span store, with table, JSON and chart output, saved queries, an "Ask AI" button that writes the query for you, and CSV export.

The dashboard is composable in the same spirit: drag-and-drop cards, resizable, each one backed by a metric or a custom SQL query. After the benchmark run it had 135 spans to chart.

AcruxCore has neither. Our analytics page groups by exactly one of day, model, session or
prompt_version and renders a fixed set of tiles and charts; the state lives in the URL, so you
can share a view as a link, but you cannot compose one. There is no way to ask our trace store an
arbitrary question.
| Feature | Laminar | AcruxCore |
|---|---|---|
| Ad-hoc SQL over spans | Yes, with saved queries and CSV export | No |
| AI-written queries | Yes | No |
| Custom dashboard panels | Yes, drag-and-drop, SQL-backed | No, fixed charts |
| Grouping options | Anything SQL expresses | day, model, session, prompt_version |
Prompt authoring and versioning
Here the shapes diverge completely. Laminar has no prompt registry — we checked the database
schema rather than only the sidebar, and there is no prompts table at all. The nearest thing is
playgrounds, one row per playground holding prompt_messages as a mutable JSONB blob. Editing
it overwrites it. There are no version numbers, no aliases, no diff, and no template variables:
their only templating dependency, mustache, is used for rendering span previews, not prompts.
So the fixture prompt had to be flattened by hand for Laminar — the {% if is_vip %}
branch resolved to its VIP text, and the {% for ticket in tickets %} loop
expanded into a literal list of two tickets. That flattened string is what lm_trace_run.py
inlines, because there is nothing on the platform to fetch it from.
On AcruxCore the same prompt is three immutable versions with production on v3 and staging on
v2, a unified diff between any two, and the conditionals and loop rendered by nunjucks at request
time.

| Feature | Laminar | AcruxCore |
|---|---|---|
| Prompt registry | None | Yes |
| Version history | None — the playground row is overwritten | Immutable, numbered |
| Environment aliases | None | production, staging, promotable |
| Diff between versions | None | Unified diff, any two versions |
| Template logic | None — no variables in prompts at all | {% if %}, {% for %}, filters |
| Runtime "render this stored prompt" call | None | prompts.render(name, alias, variables) |
Sending a live call
Laminar's playground stores messages, a model, tools and an output schema, and can be opened
directly from a traced span — a genuinely nice loop for debugging a call you just saw. It needs
a provider key saved in project settings first, and that page offers a fixed list: OpenAI,
Gemini, Groq, Anthropic, Mistral, Azure and Bedrock, with no field for a custom base URL. We
confirmed the list in their source (MODEL_PROVIDER_TO_API_KEYS in
frontend/lib/env/utils.ts); keys are stored encrypted per project rather than read from the
environment.
With an OpenAI key saved, it runs. Below is the flattened prompt returning a real completion on GPT-4o mini. Two things to notice. The messages are typed in flat, because a playground row has no variables — the VIP sentence and the ticket list are pre-rendered by hand, the same flattening described above. And the footer reports input, output and total tokens, but no cost — no latency and no cache status either.

The limitation that stands is the provider list: seven named vendors and no custom base URL, so your own gateway, a relay or a local model server cannot be called from it at all.
AcruxCore's playground runs the stored prompt against any model the gateway knows, and shows cost, cache status and latency inline with the response.

| Feature | Laminar | AcruxCore |
|---|---|---|
| Runs a stored prompt | No stored prompts to run | Yes, by name and alias |
| Provider choice | Seven named providers, no custom base URL | Any OpenAI-compatible provider |
| Variables in the playground | None — messages are typed in flat | {{ }} filled from a variables panel |
| Tokens shown inline | Yes — input, output, total | Yes |
| Cost shown inline | No | Yes |
| Cache status shown inline | No cache | Yes |
| Open from a trace span | Yes, "Experiment in playground" | Re-run from the trace |
How tools are handled
Laminar records tool calls as spans and stores a tool schema as a tools JSONB field on a
playground row. There is no tool catalog page, no version history for a tool, and nothing in
Laminar executes one — your agent does, and Laminar watches. That is consistent with sitting
beside the request path.
AcruxCore's Tool Catalog is the opposite: a tool is a first-class, versioned resource that the gateway itself calls, with a page measuring per-tool volume, error rate and P50/P95.

| Feature | Laminar | AcruxCore |
|---|---|---|
| Tool definitions stored | JSONB field on a playground row | First-class versioned resource |
| Tool executed by the platform | No | Yes |
| Per-tool analytics | No | Yes |
| Tool calls visible in traces | Yes, as spans | Yes, as spans |
Evaluation and datasets
Laminar's evaluations are unopinionated and code-first: you supply the data, an executor function and scorer functions, and it runs them locally or in CI, tracing every call and tracking scores across runs. We ran one over three triage cases.
evaluate(
data=DATA,
executor=executor,
evaluators={"mentions_topic": mentions_topic, "under_five_sentences": under_five_sentences},
name="vip-triage-v1", group_name="triage",
)
The results view puts the per-row scores beside the trace of each datapoint, with the evaluator functions themselves showing up as spans under the executor.

Datasets are built from the traces you already have — "Add to dataset" on a span turns it into a Data/Target row with no manual copying.

AcruxCore runs evaluations server-side instead — datasets, LLM judges and experiment runs from the
dashboard, plus rules that score live production spans automatically. A rule is plain-English
criteria plus a judge model, matched against incoming llm spans by prompt, alias, model or tags,
sampled (10% by default) and capped at a daily limit, producing a 0–100 score with a reason and
emailing owners when a score drops below a threshold.
The honest difference: Laminar's scorers are arbitrary code and can assert anything, but somebody has to run them. Ours run themselves against real traffic, but they are LLM judges producing a sampled numeric score, not arbitrary assertions.
| Feature | Laminar | AcruxCore |
|---|---|---|
| Where an eval runs | Your machine or CI, via SDK/CLI | Server-side, from the dashboard |
| Scorer definition | Any Python function | LLM judge with plain-English criteria |
| Define an eval in the UI | No | Yes |
| Scoring live production traffic | Signals, separately | Yes, sampled rules with alerts |
| Dataset from a trace | Yes, one click from a span | Yes |
| Compare two runs | Yes, side by side | Yes, run history |
SDK and developer experience
Laminar's onboarding is the most coding-agent-first we have seen. The empty traces page hands you
a prompt to paste into your coding agent; npx lmnr-cli setup authenticates, writes a project key
to .env and installs a "Laminar skill"; and the CLI can query your traces in SQL so the agent
can verify its own instrumentation.

AcruxCore's SDK is aimed at a different job — the prompt lives on the platform, so the script fetches and fills it, sends it through the gateway, and never mentions tracing:
async with AcruxCore(api_key=KEY, base_url=BASE_URL) as hub:
rendered = await hub.prompts.render("vip-support-triage", "production", VARIABLES)
result = await hub.gateway.chat(rendered.model, rendered.messages, prompt_version_id=rendered.version_id)
Both scripts are committed and were run before this post linked them:
lm_trace_run.py
and
acx_sdk_run.py.
| Feature | Laminar | AcruxCore |
|---|---|---|
| Lines to start tracing | One initialize() | Zero for a gateway call, one register() for a framework |
| Framework auto-instrumentation | 15+ frameworks | 5 — OpenAI, OpenAI Agents SDK, CrewAI, LangChain, LlamaIndex |
| CLI | Yes, including SQL over traces | No |
| MCP access for a coding agent | Yes | Not shipped today |
| Fetch a stored prompt at runtime | No prompts to fetch | Yes |
What Laminar does that AcruxCore doesn't
The two biggest ones — SQL over spans and the custom dashboard builder — have their own section above. Four more, each checked against our own codebase before being written down.
Labeling queues. A span can be pushed into a named queue where a reviewer works items one at a time against a defined annotation schema, then pushes the labelled result to a dataset. AcruxCore has trace feedback — a human can score a trace from −1 to 5 with a label and comment — but there is no queue: no assignment to a reviewer, no next-unlabelled-item workflow, no rubric, no completion tracking.

Signals. You describe a behaviour in plain English, give it a structured output schema, and an LLM watches traces for it — with templates for failure, logic, task, user friction, hallucination and intent, a trigger on trace or span completion, filters, and sampling. Matching events are grouped into clusters and can page you in Slack.

This one needs a caveat we will not soften. AcruxCore's online evaluation rules cover much of the same ground — plain-English criteria, an LLM judge, automatic matching against live spans, sampling, daily caps and email alerts — so this is not a capability we lack. What we lack is the event-and-cluster model on top: their output is a structured event that gets clustered into behavioural patterns, ours is a 0–100 score with a reason. Also worth saying plainly: on the lite self-hosted stack, after enabling Signals and running a fresh trace through it, no event was produced within our observation window. The builder is real and we screenshotted it; the end-to-end result we could not confirm.
The debugger. LMNR_DEBUG=1 lets a coding agent run your agent, read the resulting trace,
change the code and re-run with cached state — the iteration loop owned by the agent rather than
by you. AcruxCore has nothing equivalent, and our own MCP server is unmerged and unpublished, so
today the answer is simply no.

PII redaction. A project-level toggle runs every ingested span through a PII redactor before storage, replacing detected names, emails and phone numbers. AcruxCore always scrubs a fixed set of secret patterns — API keys, bearer tokens, AWS access keys and email addresses — and has a team-level switch to stop capturing payloads at all, but nothing that detects names, phone numbers or addresses.
Two more worth naming without a screenshot, because they are structural: Laminar delivers alerts to Slack, while AcruxCore's only notification channel is email; and Laminar records browser-agent sessions for Browser Use, Stagehand and Playwright, which we have no answer for at all. Our "Sessions" means traces grouped by a caller-supplied session id — a conversation thread, not a recording.
Is AcruxCore a Laminar alternative?
For part of what Laminar does, no — and that is the most useful thing this comparison found. Both of us take nested OTel spans, so seeing an agent run is not the dividing line. Digging into one is: SQL over your own span store, a composable dashboard, labeling queues, a transcript view, and three times the framework integrations. We do not replace that depth, and this post does not pretend otherwise.
Where we are a genuine alternative is everything that happens before the call. Laminar has no prompt registry, no request path, and therefore no way to enforce a spend cap or serve a cached response. If those are the things going wrong for you, that is the swap worth making.
They also compose, which is the answer most people actually want: point your client at our gateway and keep Laminar's SDK instrumenting the run. Nothing about the two conflicts.
Verdict
| Laminar | AcruxCore | |
|---|---|---|
| Strongest at | Digging into an agent run — SQL, dashboards, labeling, transcript views | Managing the prompts and calls that produce the run |
| Weakest at | Anything before the call — no prompt registry, no request path, no spend control — and a playground that reaches only seven named providers, with no custom base URL | Asking arbitrary questions of your own trace data |
| Pick it if | You are shipping an agent on a framework, and your hard problem is understanding its behaviour | Your prompts change often, several people edit them, and cost or access needs a control point |
These two tools barely overlap, which is the most useful thing this comparison found. Laminar starts at the trace and builds upward — SQL, dashboards, labeling, signals, a debugger — and it beats us clearly on querying trace data, and on the breadth and reading of traces — both platforms take nested OTel spans, but they have three times the integrations and far more ways to read them back. On measured latency the two are close — both sit within tens of milliseconds of a direct call, with Laminar consistently the lower of the two by about 10 ms. AcruxCore starts at the prompt and the request — versions, aliases, templates, a gateway that can refuse a call that would blow a budget — and Laminar has no answer for any of that, by design.
If you already run Laminar and your prompts are drifting across a team, the two compose fine: point your client at our gateway, keep Laminar's SDK instrumenting the run.
Licence, pricing, team model, security and community stats for both — with a source link and a checked-on date for every fact — are on the compare page.
Start here if you want to try the other side: Quickstart.