Cookbook: Laravel AI SDK
Cookbook: Laravel AI SDK
Goal: see every agent run, model call and tool call your app makes through
laravel/ai as part of the trace it ran
in, and graph latency and token spend per provider and model — in the
OpenTelemetry GenAI vocabulary your backend already understands.
There is nothing to install or wire. When laravel/ai is in the app, the
package listens to its own events (instrument.ai, default on). When it is
not, nothing is registered — no listeners, no cost.
What a run looks like
GET /chat (your request span)
└─ invoke_agent SupportAgent INTERNAL
├─ chat gpt-5-mini CLIENT ← the model asks for a tool
│ └─ POST api.openai.com CLIENT (HTTP client span)
├─ execute_tool LookupOrder INTERNAL
│ └─ select * from orders … (whatever the tool did)
└─ chat gpt-5-mini CLIENT ← the model answers
invoke_agent {agent}— one perprompt()/stream()run. The name is the agent's class, or itsname()when it implementsCanActAsTool; an anonymousagent(...)is justinvoke_agent.chat {model}— one per model call (laravel/ai calls them steps). A run that uses tools makes several.execute_tool {tool}— one per tool call. Tools are siblings of the model call that asked for them, not children: laravel/ai runs a tool after that call has returned.- An agent used as a tool nests its own
invoke_agentunder theexecute_toolspan that ran it, so a multi-agent run reads as one tree. embeddings {model}— one perEmbeddings::for(...)->generate()that reaches the provider (a cache hit makes no call and no span).
Queued agents (->queue()) continue the trace of the request that queued
them, like any other job.
Attributes
| Span | Attributes |
|---|---|
| all | gen_ai.operation.name, error.type + error status on failure |
invoke_agent |
gen_ai.provider.name, gen_ai.agent.name, gen_ai.agent.description (CanActAsTool), gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens / output_tokens / reasoning.output_tokens (summed over the run), gen_ai.response.finish_reasons, gen_ai.conversation.id (remembered conversations), gen_ai.request.stream |
chat |
gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.request.max_tokens / temperature / top_p when set, gen_ai.usage.input_tokens / output_tokens / reasoning.output_tokens / cache_read.input_tokens / cache_creation.input_tokens, gen_ai.response.finish_reasons, gen_ai.request.stream |
execute_tool |
gen_ai.tool.name, gen_ai.tool.description, gen_ai.tool.type (function) |
embeddings |
gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.embeddings.dimension.count, gen_ai.usage.input_tokens |
gen_ai.provider.name uses the conventions' well-known values where one
exists (openai, anthropic, gcp.gemini, azure.ai.openai,
aws.bedrock, mistral_ai, x_ai, groq, deepseek, cohere); any
other driver is reported as itself (ollama, openrouter, …).
gen_ai.response.finish_reasons is a string array in the conventions;
span attributes here are scalars, so it travels as its JSON encoding
(["stop"]). A token count the provider did not report is left out, never
recorded as zero.
Metrics
| Metric | Prometheus | Labels |
|---|---|---|
gen_ai.client.operation.duration (histogram, s) |
gen_ai_client_operation_duration_seconds |
gen_ai.operation.name (chat / embeddings), gen_ai.provider.name, gen_ai.request.model, error.type on failure |
gen_ai.client.token.usage (histogram, {token}) |
gen_ai_client_token_usage |
the same, plus gen_ai.token.type (input / output) |
gen_ai.client.cost (counter, {USD}) — only with prices |
gen_ai_client_cost_total |
the same as the duration, without error.type |
Both use the bucket boundaries the conventions specify, and both are recorded per model call — never per agent run, which would count the same tokens twice. Labels are the operation, provider and requested model: bounded by your configuration, never by what users type.
Token spend per model over the last day:
sum by (gen_ai_request_model, gen_ai_token_type) (
increase(gen_ai_client_token_usage_sum[1d])
)
p95 model latency, and the error rate, per provider:
histogram_quantile(0.95, sum by (le, gen_ai_provider_name) (
rate(gen_ai_client_operation_duration_seconds_bucket{gen_ai_operation_name="chat"}[5m])
))
sum by (gen_ai_provider_name) (rate(gen_ai_client_operation_duration_seconds_count{error_type!=""}[5m]))
/ sum by (gen_ai_provider_name) (rate(gen_ai_client_operation_duration_seconds_count[5m]))
Slow agent runs in Tempo:
{ span.gen_ai.operation.name = "invoke_agent" && duration > 10s }
Costs
Give the package your model prices and every priced model call carries
gen_ai.usage.cost and adds to the gen_ai.client.cost counter. No price
list ships with the package: prices change, differ per contract and region,
and a stale catalog is a wrong number that looks right. A model you have
not priced gets no cost at all, never a guess.
// config/telemetry.php
'instrument' => [
'ai_pricing' => [
'currency' => env('TELEMETRY_AI_CURRENCY', 'USD'),
// Per 1M tokens.
'models' => [
'anthropic/claude-sonnet-*' => ['input' => 3.00, 'output' => 15.00, 'cache_read' => 0.30, 'cache_write' => 3.75],
'openai/gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60, 'cache_read' => 0.075],
'openai/text-embedding-3-small' => ['input' => 0.02],
],
],
],
- Keys name a model, optionally prefixed by its provider: the laravel/ai
driver (
gemini/…) or thegen_ai.provider.namevalue (gcp.gemini/…).*matches dated snapshots. An exact key wins over a pattern, and a longer pattern over a shorter one. Matching ignores case. - The served model is priced first. The model the provider reports
(
gen_ai.response.model, often a dated snapshot) is looked up before the one you asked for. - Each token is billed once. Input tokens include cache reads and
writes, so the uncached part is billed at
input, cache reads atcache_readand cache writes atcache_write. Both fall back toinput. Reasoning tokens are part of the output and billed atoutput. Embeddings are billed atinput. - An agent run (
invoke_agent) carries the sum of its model calls, but only when every call that used tokens was priced. A partial sum would undercount the run. - The counter has the same labels as the token metric. Its unit is the
currency (
{EUR}withTELEMETRY_AI_CURRENCY=EUR). One app reports in one currency.
The conventions define no cost attribute yet, so gen_ai.usage.cost and
gen_ai.client.cost are this package's names.
Spend per model over the last day, and the models that used tokens but have no price:
sum by (gen_ai_request_model) (increase(gen_ai_client_cost_total[1d]))
sum by (gen_ai_provider_name, gen_ai_request_model) (increase(gen_ai_client_token_usage_count[1d]))
unless on (gen_ai_provider_name, gen_ai_request_model)
sum by (gen_ai_provider_name, gen_ai_request_model) (increase(gen_ai_client_cost_total[1d]))
The most expensive runs in Tempo:
{ span.gen_ai.operation.name = "invoke_agent" && span.gen_ai.usage.cost > 0.5 }
Per user, team and tenant
Every AI span carries what you need to break usage down by who caused it:
- The signed-in user (
user.id), when the request has already resolved one. The instrumentation never resolves it itself, so it adds no auth query. Off withinstrument.user. - Your dimensions from
Telemetry::context()(team, tenant, plan, …), set once in middleware. Queued jobs inherit them. - The agent (
gen_ai.agent.name), on the agent run and on each of its model calls.
{ span.tenant.id = "7" && span.gen_ai.operation.name = "chat" }
Metric labels stay bounded, so ids never become labels. Two opt-ins cover the totals:
Bounded dimensions as labels. instrument.ai_metric_labels adds
Telemetry::context() keys with few values to all three gen_ai.client.*
metrics, for exact totals in Prometheus:
'ai_metric_labels' => ['plan'], // pro / team / free — never an id
sum by (plan) (increase(gen_ai_client_cost_total[30d]))
One record per call, for exact per-id totals. instrument.ai_usage_events
(TELEMETRY_INSTRUMENT_AI_USAGE_EVENTS=true) records a
gen_ai.client.inference.operation.details log record for every model
call, linked to its span. It carries the call's gen_ai.* attributes
(models, tokens with cache and reasoning, finish reason, cost), user.id,
your context dimensions and error.type. It never carries prompt or
completion content, even with instrument.ai_content on. Log records are
never sampled, so sums over them are exact whatever the trace sample rate:
# Spend per tenant, last 30 days
sum by (tenant_id) (
sum_over_time({service_name="my-app"} |= "gen_ai.client.inference.operation.details" | unwrap gen_ai_usage_cost [30d])
)
# Top users by tokens today
topk(10, sum by (user_id) (
sum_over_time({service_name="my-app"} |= "gen_ai.client.inference.operation.details" | unwrap gen_ai_usage_input_tokens [1d])
))
These are operational numbers. If you invoice on them, reconcile against the provider's own usage export.
Prompts and completions (opt-in)
By default no content is recorded — not the prompt, not the answer, not the system instructions, not tool arguments or results. That is what the GenAI conventions ask for: it is user data.
To record it, for a staging environment or a deliberate production decision:
TELEMETRY_INSTRUMENT_AI_CONTENT=true
TELEMETRY_INSTRUMENT_AI_CONTENT_MAX_BYTES=2048
Spans then also carry, as JSON in the conventions' message shape:
invoke_agent:gen_ai.system_instructions,gen_ai.input.messages(the prompt),gen_ai.output.messages(the answer)chat:gen_ai.input.messages(the full history sent: prompt, earlier tool calls, tool results) andgen_ai.output.messages(text and the tool calls the model asked for)execute_tool:gen_ai.tool.call.arguments,gen_ai.tool.call.result
Attachments are never recorded. The byte budget applies to each attribute
and is shared across the messages it carries, so a long history cannot
grow without bound; every piece is cut on a UTF-8 boundary and marked with
…, and the JSON stays valid.
Captured content goes through the same redaction as every other attribute
when it is exported: the credential patterns (JWTs, Bearer/Basic
tokens, credentials in URLs — not every secret-looking word in free text),
the personal-identifier detectors if you turned them on (redaction.pii —
worth doing before you record what users type), your own
Telemetry::redactUsing() hook, and
redaction.max_value_length (4096 by default), which caps the whole
attribute after redaction. Keep the content budget below that cap, or a
long value is cut mid-JSON. To scrub something specific to your domain:
// Return the replacement, or null to keep the value.
Telemetry::redactUsing(function (string $key, string $value): ?string {
if (! str_starts_with($key, 'gen_ai.')) {
return null;
}
return preg_replace('/\bORD-\d{6}\b/', '[ORDER]', $value);
});
See Runtime hooks for the full redaction model.
Failures and failover
- A model call that throws ends its
chatspan with the exception andgen_ai.response.finish_reasons = ["error"]; its duration is recorded witherror.type, and no tokens are. - A tool that throws fails its
execute_toolspan; the run fails with it. - Failover: when an agent has several providers and one gives up with a
failover exception (rate limited, overloaded, …), that attempt is its own
invoke_agentspan, ended as an error; the next provider's attempt is a newinvoke_agentspan in the same trace. An embeddings call that fails over does the same with itsembeddingsspan.
Your application sees every exception exactly as it would without the package — telemetry never throws, and never swallows.
Limits
- A stream nobody finishes reading reports no end, so its spans are never exported: a missing span is preferred to one with a made-up duration. Read the stream to the end (returning it from a controller does) and the run is recorded. Until the request ends, spans started after an abandoned stream nest under its run; the request's own span and its tallies are unaffected.
- An embeddings call that throws something other than a failover exception reports no end either; its span is dropped for the same reason. Embeddings spans are not the ambient span for that reason, so the provider's HTTP span sits beside it rather than under it.
- Tool call ids: laravel/ai's tool events do not carry the provider's
call id, so
execute_toolhas nogen_ai.tool.call.id. With content capture on, thechatspan's output still lists each call with its id.
Conventions version
Names follow the OpenTelemetry GenAI semantic conventions as last released
with the main conventions (v1.41.1). The conventions have since moved to
their own repository, where unreleased work replaces
gen_ai.client.token.usage with per-direction counters
(gen_ai.client.inference.usage.input_tokens, …), adds
gen_ai.client.inference.duration, and renames
gen_ai.usage.cache_creation.input_tokens to …cache_write…. This package
will follow once that is released; the span names and the attributes above
are the same in both.
Turning it off
TELEMETRY_INSTRUMENT_AI=false
Testing
use Cbox\Telemetry\Facades\Telemetry;
it('answers within budget', function () {
$telemetry = Telemetry::fake();
SupportAgent::fake(['Your order shipped yesterday.']);
(new SupportAgent)->prompt('Where is my order?');
$telemetry->assertSpanRecorded('invoke_agent SupportAgent');
$telemetry->assertHistogramRecorded('gen_ai.client.operation.duration');
});