Skip to content

Querying Traces

Once traces are collected, querying and analyzing them becomes essential for debugging production issues, optimizing performance, monitoring costs, and understanding LLM usage patterns across your application.

Overview

Why Query Traces?

Traces provide a detailed record of every LLM operation in your application. By querying traces effectively, you can:

  • Debug Production Issues - Find failed requests, trace errors back to their source, and understand what went wrong
  • Optimize Performance - Identify slow operations, bottlenecks, and opportunities for caching
  • Monitor Costs - Track spending by model, user, feature, or time period
  • Understand Usage - Analyze token consumption, popular models, and user behavior patterns
  • Improve Quality - Review actual LLM interactions to optimize prompts and configurations

Query Capabilities

Mindwave stores traces in multiple backends, each with different querying capabilities:

  • Database (Eloquent) - Powerful SQL queries with Laravel's query builder
  • Jaeger - Time-range searches, tag filtering, TraceQL support
  • Honeycomb - Advanced aggregations, custom visualizations, and query builder
  • Zipkin - Basic search by service, operation, and tags

This guide covers querying across all backends, organized by common use cases.


Trace Structure in Mindwave

Understanding the structure of traces helps you write effective queries.

Span Hierarchy

Traces are composed of spans arranged in a parent-child hierarchy:

  • Trace - The top-level container for a complete operation
  • Root Span - The first span in a trace (no parent)
  • Child Spans - Nested spans that belong to a parent

Example hierarchy:

Trace (trace_id: abc123)
└── Root Span: "llm.chat" (span_id: def456)
    ├── Child Span: "context.discover" (span_id: ghi789)
    │   ├── "vectorstore.search" (span_id: jkl012)
    │   └── "database.query" (span_id: mno345)
    └── Child Span: "llm.stream" (span_id: pqr678)

Attributes Added by Mindwave

Mindwave automatically adds rich attributes to every span following OpenTelemetry GenAI semantic conventions:

LLM Operation Attributes

AttributeDescriptionExample
gen_ai.operation.nameType of operationchat, text_completion, embeddings
gen_ai.provider.nameLLM provideropenai, anthropic, mistral
gen_ai.request.modelRequested modelgpt-4-turbo, claude-3-opus
gen_ai.response.modelActual response modelgpt-4-turbo-2024-04-09

Token Usage Attributes

AttributeDescriptionExample
gen_ai.usage.input_tokensInput/prompt tokens150
gen_ai.usage.output_tokensOutput/completion tokens300
gen_ai.usage.total_tokensTotal tokens used450
gen_ai.usage.cache_read_tokensCache read tokens (Anthropic)100
gen_ai.usage.cache_creation_tokensCache creation tokens (Anthropic)50

Request Parameters

AttributeDescriptionExample
gen_ai.request.temperatureSampling temperature0.7
gen_ai.request.max_tokensMaximum output tokens1000
gen_ai.request.top_pNucleus sampling parameter0.9
gen_ai.request.frequency_penaltyFrequency penalty0.5
gen_ai.request.presence_penaltyPresence penalty0.5

Response Metadata

AttributeDescriptionExample
gen_ai.response.idProvider response IDchatcmpl-abc123
gen_ai.response.finish_reasonsWhy generation stopped["stop"], ["length"], ["tool_calls"]

Span Metadata

FieldDescriptionExample
nameSpan nameopenai.chat
kindSpan kindclient, server, internal
status_codeStatusok, error
durationDuration in nanoseconds1500000000 (1.5s)
start_timeStart timestamp (ns)1732045200000000000
end_timeEnd timestamp (ns)1732045201500000000

Events Recorded

Spans can contain events - timestamped occurrences during execution:

  • Token streaming events - Individual token deliveries during streaming
  • Exception events - Detailed error information
  • Custom events - Application-specific markers

Span Naming Conventions

Mindwave follows these naming patterns:

  • LLM calls: {provider}.{operation} (e.g., openai.chat, anthropic.chat)
  • Context discovery: context.discover
  • Vector searches: vectorstore.{operation} (e.g., vectorstore.search)
  • Tool executions: tool.{tool_name}
  • Custom operations: User-defined names

Querying with Eloquent (Database)

The most powerful way to query Mindwave traces is through Laravel's Eloquent ORM and the database backend.

Basic Queries

Find Recent LLM Calls

php
use Mindwave\Mindwave\Observability\Models\Trace;

// Get the last 10 traces
$traces = Trace::with('spans')
    ->orderBy('created_at', 'desc')
    ->limit(10)
    ->get();

foreach ($traces as $trace) {
    echo "Trace: {$trace->trace_id}\n";
    echo "Duration: {$trace->getDurationInMilliseconds()}ms\n";
    echo "Cost: \${$trace->estimated_cost}\n";
    echo "Spans: {$trace->spans->count()}\n\n";
}

Find Traces by Time Range

php
use Carbon\Carbon;

// Traces from the last hour
$recentTraces = Trace::where('created_at', '>=', Carbon::now()->subHour())
    ->orderBy('created_at', 'desc')
    ->get();

// Traces from yesterday
$yesterday = Trace::whereDate('created_at', Carbon::yesterday())
    ->get();

// Traces between specific dates
$traces = Trace::whereBetween('created_at', [
    Carbon::parse('2025-01-01'),
    Carbon::parse('2025-01-31'),
])->get();

Query Specific Spans

php
use Mindwave\Mindwave\Observability\Models\Span;

// Find all GPT-4 calls
$gpt4Spans = Span::where('request_model', 'like', 'gpt-4%')
    ->with('trace')
    ->get();

// Find spans by provider
$openaiSpans = Span::provider('openai')
    ->orderBy('created_at', 'desc')
    ->get();

// Find spans by operation
$chatSpans = Span::operation('chat')
    ->get();

Performance Analysis

Find Slow Operations

php
// Spans that took longer than 5 seconds
$slowSpans = Span::slow(5000) // 5000ms
    ->with('trace')
    ->orderBy('duration', 'desc')
    ->get();

foreach ($slowSpans as $span) {
    echo "Slow operation: {$span->name}\n";
    echo "Model: {$span->request_model}\n";
    echo "Duration: {$span->getDurationInMilliseconds()}ms\n";
    echo "Tokens: {$span->getTotalTokens()}\n\n";
}

Calculate P95 and P99 Latency

php
use Illuminate\Support\Facades\DB;

// Get percentile latencies for a specific model
$stats = Span::where('request_model', 'gpt-4-turbo')
    ->whereNotNull('duration')
    ->selectRaw('
        MIN(duration) / 1000000 as min_ms,
        AVG(duration) / 1000000 as avg_ms,
        MAX(duration) / 1000000 as max_ms
    ')
    ->first();

// For P95/P99, you may need raw SQL depending on your database
// MySQL 8.0+ / PostgreSQL example:
$percentiles = DB::table('mindwave_spans')
    ->where('request_model', 'gpt-4-turbo')
    ->whereNotNull('duration')
    ->selectRaw('
        PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY duration) / 1000000 as p50_ms,
        PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration) / 1000000 as p95_ms,
        PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration) / 1000000 as p99_ms
    ')
    ->first();

Find Slowest Models

php
$slowestModels = Span::whereNotNull('request_model')
    ->whereNotNull('duration')
    ->selectRaw('
        request_model,
        AVG(duration) / 1000000 as avg_duration_ms,
        COUNT(*) as total_calls
    ')
    ->groupBy('request_model')
    ->orderByDesc('avg_duration_ms')
    ->limit(10)
    ->get();

foreach ($slowestModels as $model) {
    echo "{$model->request_model}: {$model->avg_duration_ms}ms avg ({$model->total_calls} calls)\n";
}

Identify Context Discovery Bottlenecks

php
// Find slow context discovery operations
$slowDiscovery = Span::where('name', 'like', 'context.%')
    ->slow(1000) // Over 1 second
    ->with('trace', 'children')
    ->get();

foreach ($slowDiscovery as $span) {
    echo "Slow context discovery: {$span->getDurationInMilliseconds()}ms\n";

    // Check which child operations were slow
    foreach ($span->children as $child) {
        echo "  - {$child->name}: {$child->getDurationInMilliseconds()}ms\n";
    }
}

Cost Analysis

Find Expensive LLM Calls

php
// Find individual calls costing more than $0.10
$expensive = Span::whereNotNull('input_tokens')
    ->whereNotNull('output_tokens')
    ->get()
    ->filter(function ($span) {
        // Calculate cost per span
        return $this->estimateSpanCost($span) > 0.10;
    });

// Or find expensive traces
$expensiveTraces = Trace::expensive(0.10)
    ->orderBy('estimated_cost', 'desc')
    ->get();

foreach ($expensiveTraces as $trace) {
    echo "Expensive trace: {$trace->trace_id}\n";
    echo "Cost: \${$trace->estimated_cost}\n";
    echo "Tokens: {$trace->getTotalTokens()}\n\n";
}

Daily Cost Report

php
use Carbon\Carbon;

$dailyCosts = Trace::whereDate('created_at', '>=', Carbon::now()->subDays(30))
    ->selectRaw('
        DATE(created_at) as date,
        SUM(estimated_cost) as total_cost,
        COUNT(*) as total_calls,
        SUM(total_input_tokens + total_output_tokens) as total_tokens
    ')
    ->groupBy('date')
    ->orderBy('date')
    ->get();

foreach ($dailyCosts as $day) {
    echo "{$day->date}: \${$day->total_cost} ({$day->total_calls} calls, {$day->total_tokens} tokens)\n";
}

Cost by Provider

php
$costByProvider = DB::table('mindwave_spans')
    ->join('mindwave_traces', 'mindwave_spans.trace_id', '=', 'mindwave_traces.trace_id')
    ->whereNotNull('provider_name')
    ->selectRaw('
        provider_name,
        COUNT(DISTINCT mindwave_traces.id) as trace_count,
        COUNT(mindwave_spans.id) as span_count,
        SUM(mindwave_traces.estimated_cost) as total_cost,
        AVG(mindwave_traces.estimated_cost) as avg_cost
    ')
    ->groupBy('provider_name')
    ->orderByDesc('total_cost')
    ->get();

foreach ($costByProvider as $provider) {
    echo "{$provider->provider_name}:\n";
    echo "  Total Cost: \${$provider->total_cost}\n";
    echo "  Traces: {$provider->trace_count}\n";
    echo "  Avg Cost/Trace: \${$provider->avg_cost}\n\n";
}

Cost by Model

php
$costByModel = Span::whereNotNull('request_model')
    ->selectRaw('
        request_model,
        COUNT(*) as calls,
        SUM(input_tokens) as total_input,
        SUM(output_tokens) as total_output,
        SUM(input_tokens + output_tokens) as total_tokens
    ')
    ->groupBy('request_model')
    ->orderByDesc('total_tokens')
    ->get();

// You'll need to calculate cost based on your pricing config
foreach ($costByModel as $model) {
    echo "{$model->request_model}:\n";
    echo "  Calls: {$model->calls}\n";
    echo "  Input Tokens: {$model->total_input}\n";
    echo "  Output Tokens: {$model->total_output}\n";
    echo "  Total Tokens: {$model->total_tokens}\n\n";
}

Monthly Budget Tracking

php
use Carbon\Carbon;

$monthlyBudget = 100.00; // $100 budget

$currentMonth = Trace::whereMonth('created_at', Carbon::now()->month)
    ->whereYear('created_at', Carbon::now()->year)
    ->selectRaw('SUM(estimated_cost) as total_cost')
    ->first();

$spent = $currentMonth->total_cost ?? 0;
$remaining = $monthlyBudget - $spent;
$percentUsed = ($spent / $monthlyBudget) * 100;

echo "Monthly Budget: \${$monthlyBudget}\n";
echo "Spent: \${$spent} ({$percentUsed}%)\n";
echo "Remaining: \${$remaining}\n";

if ($percentUsed > 80) {
    echo "WARNING: 80% of budget used!\n";
}

Debugging

Find Failed LLM Calls

php
// Find traces with errors
$failedTraces = Trace::where('status', 'error')
    ->with('spans')
    ->orderBy('created_at', 'desc')
    ->get();

foreach ($failedTraces as $trace) {
    echo "Failed trace: {$trace->trace_id}\n";
    echo "Time: {$trace->created_at}\n";

    // Find which span failed
    $errorSpan = $trace->spans->first(fn($s) => $s->status_code === 'error');
    if ($errorSpan) {
        echo "Failed span: {$errorSpan->name}\n";
        echo "Error: {$errorSpan->status_description}\n";
    }
    echo "\n";
}

// Find spans with errors
$errorSpans = Span::withErrors()
    ->with('trace')
    ->orderBy('created_at', 'desc')
    ->get();

Trace by Request ID

php
// Find a specific trace by ID
$trace = Trace::where('trace_id', 'your-trace-id-here')
    ->with('spans.children')
    ->first();

if ($trace) {
    echo "Trace: {$trace->trace_id}\n";
    echo "Status: {$trace->status}\n";
    echo "Duration: {$trace->getDurationInMilliseconds()}ms\n";
    echo "Cost: \${$trace->estimated_cost}\n\n";

    echo "Span tree:\n";
    foreach ($trace->spans as $span) {
        $indent = $span->parent_span_id ? "  " : "";
        echo "{$indent}{$span->name} ({$span->getDurationInMilliseconds()}ms)\n";
    }
}

Find Error Patterns

php
// Group errors by model
$errorsByModel = Span::withErrors()
    ->selectRaw('
        request_model,
        COUNT(*) as error_count,
        status_description
    ')
    ->groupBy('request_model', 'status_description')
    ->orderByDesc('error_count')
    ->get();

foreach ($errorsByModel as $error) {
    echo "{$error->request_model}: {$error->error_count} errors\n";
    echo "  Message: {$error->status_description}\n\n";
}

Find Timeout Issues

php
// Assuming timeouts result in specific error messages
$timeouts = Span::withErrors()
    ->where('status_description', 'like', '%timeout%')
    ->orWhere('status_description', 'like', '%timed out%')
    ->with('trace')
    ->get();

foreach ($timeouts as $span) {
    echo "Timeout in: {$span->name}\n";
    echo "Model: {$span->request_model}\n";
    echo "Duration: {$span->getDurationInMilliseconds()}ms\n";
    echo "Error: {$span->status_description}\n\n";
}

Usage Analytics

php
use Carbon\Carbon;

// Daily token usage for the last 30 days
$tokenTrends = Trace::whereDate('created_at', '>=', Carbon::now()->subDays(30))
    ->selectRaw('
        DATE(created_at) as date,
        SUM(total_input_tokens) as input_tokens,
        SUM(total_output_tokens) as output_tokens,
        SUM(total_input_tokens + total_output_tokens) as total_tokens
    ')
    ->groupBy('date')
    ->orderBy('date')
    ->get();

foreach ($tokenTrends as $day) {
    echo "{$day->date}: {$day->total_tokens} tokens ";
    echo "(in: {$day->input_tokens}, out: {$day->output_tokens})\n";
}

Model Usage Distribution

php
$modelDistribution = Span::whereNotNull('request_model')
    ->selectRaw('
        request_model,
        COUNT(*) as usage_count,
        SUM(input_tokens + output_tokens) as total_tokens
    ')
    ->groupBy('request_model')
    ->orderByDesc('usage_count')
    ->get();

$totalCalls = $modelDistribution->sum('usage_count');

foreach ($modelDistribution as $model) {
    $percentage = ($model->usage_count / $totalCalls) * 100;
    echo "{$model->request_model}: {$model->usage_count} calls ({$percentage}%)\n";
    echo "  Tokens: {$model->total_tokens}\n";
}

Peak Usage Times

php
$hourlyUsage = Trace::selectRaw('
        HOUR(created_at) as hour,
        COUNT(*) as call_count,
        AVG(estimated_cost) as avg_cost
    ')
    ->groupBy('hour')
    ->orderBy('hour')
    ->get();

foreach ($hourlyUsage as $hour) {
    echo "Hour {$hour->hour}:00 - {$hour->call_count} calls, \${$hour->avg_cost} avg cost\n";
}

User Activity Patterns

php
// Assuming you add user_id as metadata or custom attribute
$userActivity = DB::table('mindwave_traces')
    ->whereNotNull('metadata->user_id')
    ->selectRaw("
        JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.user_id')) as user_id,
        COUNT(*) as call_count,
        SUM(estimated_cost) as total_cost,
        SUM(total_input_tokens + total_output_tokens) as total_tokens
    ")
    ->groupBy('user_id')
    ->orderByDesc('call_count')
    ->limit(20)
    ->get();

foreach ($userActivity as $user) {
    echo "User {$user->user_id}: {$user->call_count} calls, ";
    echo "\${$user->total_cost}, {$user->total_tokens} tokens\n";
}

Querying in Jaeger

Jaeger provides a powerful web UI for trace visualization and search.

UI Overview

Access Jaeger at http://localhost:16686 (default) or your configured endpoint.

Key sections:

  • Search - Find traces by service, operation, tags, and time
  • Trace View - Visualize span hierarchy and timing
  • Compare - Side-by-side trace comparison

Search by Service

  1. Select Service: mindwave-app (or your configured service name)
  2. Select Operation: openai.chat or All
  3. Set Lookback: Last hour, 2 hours, custom range
  4. Click Find Traces

Search by Tags

Filter traces using tags (attributes):

  • gen_ai.provider.name=openai - Only OpenAI calls
  • gen_ai.request.model=gpt-4-turbo - Specific model
  • gen_ai.operation.name=chat - Chat operations only
  • error=true - Failed traces

Example multi-tag search:

gen_ai.provider.name=anthropic
gen_ai.request.model=claude-3-opus

Time Range Selection

  • Lookback: Quick presets (last hour, 6 hours, 12 hours, etc.)
  • Custom: Specify exact start and end times

Advanced Filtering

Find Expensive Operations

Jaeger doesn't natively support cost filtering, but you can:

  1. Search for specific high-cost models:
    • Tag: gen_ai.request.model=gpt-4 (expensive models)
  2. Filter by token count (if you add custom tags):
    • Tag: gen_ai.usage.total_tokens>10000

Find Slow Requests

  1. Set Min Duration: e.g., 5s (5 seconds)
  2. Set Max Duration: optional upper limit
  3. Click Find Traces

This shows only traces slower than the minimum duration.

Filter by Status

Find errors:

  1. Tag: error=true
  2. Or: status.code=error

TraceQL Queries (Jaeger v1.35+)

If your Jaeger supports TraceQL:

traceql
# Find traces with high token usage
{ span.gen_ai.usage.total_tokens > 5000 }

# Find expensive Claude calls
{
  span.gen_ai.provider.name = "anthropic" &&
  span.gen_ai.request.model =~ "claude-3-opus.*"
}

# Find slow chat operations
{
  span.gen_ai.operation.name = "chat" &&
  duration > 5s
}

# Find errors in specific model
{
  span.gen_ai.request.model = "gpt-4-turbo" &&
  status = error
}

Querying in Honeycomb

Honeycomb provides the most powerful query and visualization capabilities.

UI Overview

Access Honeycomb at https://ui.honeycomb.io and select your dataset.

Key features:

  • Query Builder - Visual query construction
  • Visualizations - Heatmaps, histograms, time series
  • Boards - Custom dashboards
  • Triggers - Automated alerts

Query Builder

Basic Query Structure

  1. Visualize: Choose metric (COUNT, AVG, P95, etc.)
  2. WHERE: Filter conditions
  3. GROUP BY: Dimension to group by
  4. Time Range: Select period

Example: Find All LLM Calls Over $0.10

  1. WHERE: Add filter
    • Field: gen_ai.usage.total_tokens
    • Operator: >
    • Value: Calculate tokens for $0.10 (varies by model, e.g., ~3000 for GPT-4)
  2. GROUP BY: gen_ai.request.model
  3. VISUALIZE: COUNT

Better approach if you add cost as custom attribute:

  1. WHERE: span.cost_usd > 0.10
  2. GROUP BY: gen_ai.request.model
  3. VISUALIZE: SUM(span.cost_usd)

Example: Slowest Context Discovery Operations

  1. WHERE:
    • name contains context.
    • duration_ms > 1000
  2. GROUP BY: name
  3. VISUALIZE: P95(duration_ms)
  4. ORDER BY: P95(duration_ms) DESC

Example: Token Usage by User

  1. WHERE: None (or filter by date range)
  2. GROUP BY: user_id (requires custom attribute)
  3. VISUALIZE: SUM(gen_ai.usage.total_tokens)
  4. Time Series: Check for trend over time

Example: Error Rate by Model

  1. VISUALIZE:
    • COUNT where status.code = error as errors
    • COUNT as total
    • Calculate rate: errors / total * 100
  2. GROUP BY: gen_ai.request.model
  3. ORDER BY: Error rate DESC

Creating Boards

Boards are custom dashboards with multiple visualizations.

Cost Monitoring Board

Create a board with these queries:

1. Total Daily Spend

  • Visualize: SUM(cost_usd) (custom attribute)
  • Group by: None
  • Time Series: Daily
  • Graph: Line chart

2. Cost by Provider

  • Visualize: SUM(cost_usd)
  • Group by: gen_ai.provider.name
  • Graph: Bar chart

3. Cost by Model

  • Visualize: SUM(cost_usd)
  • Group by: gen_ai.request.model
  • Graph: Pie chart

4. Top 10 Expensive Traces

  • Visualize: MAX(cost_usd)
  • Group by: trace.trace_id
  • Order by: MAX(cost_usd) DESC
  • Limit: 10

Performance Dashboard

1. P95 Latency by Model

  • Visualize: P95(duration_ms)
  • Group by: gen_ai.request.model
  • Graph: Bar chart

2. Request Rate

  • Visualize: COUNT
  • Time Series: 1-hour buckets
  • Graph: Area chart

3. Error Rate

  • Visualize: COUNT where status.code = error / COUNT * 100
  • Time Series: 1-hour buckets
  • Graph: Line chart with threshold alert

4. Latency Heatmap

  • Visualize: HEATMAP(duration_ms)
  • Group by: gen_ai.request.model
  • Time range: Last 24 hours

Alert Configuration

Set up triggers for automated alerts:

High Cost Alert

  • Query: SUM(cost_usd) over 1 hour
  • Threshold: > $10
  • Alert: Slack, email, PagerDuty

Error Rate Alert

  • Query: COUNT where status.code = error / COUNT * 100
  • Threshold: > 5% (more than 5% error rate)
  • Alert: Immediate notification

Slow Request Alert

  • Query: P95(duration_ms)
  • Threshold: > 10000 (10 seconds)
  • Alert: Warning notification

Querying in Zipkin

Zipkin provides basic but effective trace querying.

UI Overview

Access Zipkin at http://localhost:9411 (default).

Search Capabilities

Basic Search

  1. Service Name: Select mindwave-app
  2. Span Name: Optional filter (e.g., openai.chat)
  3. Lookback: Time range to search
  4. Min Duration: Filter slow traces
  5. Click Search

Tag-based Filtering

Click + next to tags to add filters:

  • gen_ai.provider.name: Filter by provider
  • gen_ai.request.model: Filter by model
  • gen_ai.operation.name: Filter by operation type
  • error: Show only errors

Search by span annotations (events):

  • error - Traces with errors
  • Custom annotations you've added

Example Queries

Find all OpenAI GPT-4 calls:

  • Service: mindwave-app
  • Tags: gen_ai.provider.name=openai, gen_ai.request.model=gpt-4-turbo

Find slow traces:

  • Service: mindwave-app
  • Min Duration: 5000000 (5000ms in microseconds)

Find errors:

  • Service: mindwave-app
  • Tags: error=true

Programmatic Querying

Using Artisan Commands

Mindwave provides Artisan commands for querying and exporting traces.

View Trace Statistics

bash
# Overall statistics
php artisan mindwave:trace-stats

# Filter by provider
php artisan mindwave:trace-stats --provider=openai

# Filter by model
php artisan mindwave:trace-stats --model=gpt-4-turbo

# Filter by date range
php artisan mindwave:trace-stats --since=yesterday
php artisan mindwave:trace-stats --since="2025-01-01"

Output includes:

  • Total traces and spans
  • Token usage (input, output, total)
  • Cost analysis (total, average, min, max)
  • Performance metrics (avg, min, max duration)
  • Top models by usage and cost
  • Error analysis

Export Traces

bash
# Export to JSON
php artisan mindwave:export-traces --format=json --output=traces.json

# Export to CSV
php artisan mindwave:export-traces --format=csv --output=traces.csv

# Export to NDJSON (newline-delimited JSON)
php artisan mindwave:export-traces --format=ndjson --output=traces.ndjson

# Export with filters
php artisan mindwave:export-traces \
    --provider=openai \
    --min-cost=0.05 \
    --slow=5000 \
    --since="2025-01-01" \
    --format=json \
    --output=expensive-slow-traces.json

Export options:

  • --since - Export from date (e.g., "yesterday", "2025-01-01")
  • --until - Export until date
  • --provider - Filter by provider
  • --min-cost - Minimum cost in USD
  • --slow - Minimum duration in milliseconds
  • --format - Output format (csv, json, ndjson)
  • --output - Output file (default: stdout)

Building Custom Queries in Code

Create a Cost Report Service

php
namespace App\Services;

use Mindwave\Mindwave\Observability\Models\Trace;
use Mindwave\Mindwave\Observability\Models\Span;
use Carbon\Carbon;

class TraceAnalytics
{
    public function getDailyCostReport(Carbon $date): array
    {
        $traces = Trace::whereDate('created_at', $date)->get();

        return [
            'date' => $date->toDateString(),
            'total_cost' => $traces->sum('estimated_cost'),
            'total_calls' => $traces->count(),
            'total_tokens' => $traces->sum('total_input_tokens') +
                             $traces->sum('total_output_tokens'),
            'by_provider' => $this->groupByProvider($traces),
            'by_model' => $this->groupByModel($traces),
        ];
    }

    private function groupByProvider($traces): array
    {
        return $traces->load('spans')
            ->flatMap->spans
            ->groupBy('provider_name')
            ->map(function ($spans, $provider) {
                return [
                    'provider' => $provider,
                    'calls' => $spans->count(),
                    'tokens' => $spans->sum('input_tokens') +
                               $spans->sum('output_tokens'),
                ];
            })
            ->values()
            ->toArray();
    }

    private function groupByModel($traces): array
    {
        return $traces->load('spans')
            ->flatMap->spans
            ->whereNotNull('request_model')
            ->groupBy('request_model')
            ->map(function ($spans, $model) {
                return [
                    'model' => $model,
                    'calls' => $spans->count(),
                    'tokens' => $spans->sum('input_tokens') +
                               $spans->sum('output_tokens'),
                ];
            })
            ->values()
            ->toArray();
    }

    public function getSlowOperations(int $thresholdMs = 5000): array
    {
        return Span::slow($thresholdMs)
            ->with('trace')
            ->get()
            ->map(function ($span) {
                return [
                    'trace_id' => $span->trace_id,
                    'span_id' => $span->span_id,
                    'name' => $span->name,
                    'model' => $span->request_model,
                    'duration_ms' => $span->getDurationInMilliseconds(),
                    'tokens' => $span->getTotalTokens(),
                    'timestamp' => $span->created_at,
                ];
            })
            ->toArray();
    }

    public function getErrorAnalysis(Carbon $startDate, Carbon $endDate): array
    {
        $total = Trace::whereBetween('created_at', [$startDate, $endDate])->count();
        $errors = Trace::where('status', 'error')
            ->whereBetween('created_at', [$startDate, $endDate])
            ->get();

        return [
            'total_traces' => $total,
            'error_count' => $errors->count(),
            'error_rate' => $total > 0 ? ($errors->count() / $total) * 100 : 0,
            'errors_by_model' => $this->groupErrorsByModel($errors),
        ];
    }

    private function groupErrorsByModel($errors): array
    {
        return $errors->load('spans')
            ->flatMap->spans
            ->where('status_code', 'error')
            ->groupBy('request_model')
            ->map(function ($spans, $model) {
                return [
                    'model' => $model,
                    'count' => $spans->count(),
                    'common_errors' => $spans->pluck('status_description')
                        ->countBy()
                        ->sortDesc()
                        ->take(3)
                        ->toArray(),
                ];
            })
            ->values()
            ->toArray();
    }
}

Use in Controller

php
namespace App\Http\Controllers;

use App\Services\TraceAnalytics;
use Carbon\Carbon;

class AnalyticsController extends Controller
{
    public function __construct(
        private TraceAnalytics $analytics
    ) {}

    public function dailyReport()
    {
        $report = $this->analytics->getDailyCostReport(Carbon::today());

        return view('analytics.daily', compact('report'));
    }

    public function performance()
    {
        $slowOps = $this->analytics->getSlowOperations(5000);

        return view('analytics.performance', compact('slowOps'));
    }

    public function errors()
    {
        $analysis = $this->analytics->getErrorAnalysis(
            Carbon::now()->subWeek(),
            Carbon::now()
        );

        return view('analytics.errors', compact('analysis'));
    }
}

Exporting to External Systems

Export to CSV for Analysis

php
use Mindwave\Mindwave\Observability\Models\Trace;
use League\Csv\Writer;

$csv = Writer::createFromPath('traces.csv', 'w+');
$csv->insertOne(['trace_id', 'date', 'duration_ms', 'cost', 'tokens', 'model']);

Trace::with('spans')
    ->chunk(100, function ($traces) use ($csv) {
        foreach ($traces as $trace) {
            $rootSpan = $trace->spans->firstWhere('parent_span_id', null);

            $csv->insertOne([
                $trace->trace_id,
                $trace->created_at->toDateString(),
                $trace->getDurationInMilliseconds(),
                $trace->estimated_cost,
                $trace->getTotalTokens(),
                $rootSpan?->request_model ?? 'N/A',
            ]);
        }
    });

Send to Analytics Platform

php
use Mindwave\Mindwave\Observability\Events\LlmResponseCompleted;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;

Event::listen(LlmResponseCompleted::class, function ($event) {
    // Send to your analytics platform
    Http::post('https://analytics.example.com/events', [
        'event' => 'llm_call_completed',
        'trace_id' => $event->span->trace_id,
        'model' => $event->span->request_model,
        'tokens' => $event->span->getTotalTokens(),
        'duration_ms' => $event->span->getDurationInMilliseconds(),
        'cost' => $event->calculateCost(), // You'd implement this
        'timestamp' => now(),
    ]);
});

Best Practices

What to Query For

Regular Monitoring:

  • Daily cost and token usage
  • Error rates by model
  • P95/P99 latency trends
  • Peak usage times

Performance Optimization:

  • Slowest operations by type
  • Models with highest latency variance
  • Context discovery bottlenecks
  • Cache hit rates (if using prompt caching)

Cost Optimization:

  • Most expensive models or features
  • Users or features driving costs
  • Opportunities to switch to cheaper models
  • Identify prompt inefficiencies (high token usage)

Debugging:

  • Recent errors and failure patterns
  • Specific trace IDs from logs
  • Timeout occurrences
  • Unusual token usage

Query Performance Optimization

Use Indexes

Ensure your database has proper indexes:

php
// In migration
Schema::table('mindwave_spans', function (Blueprint $table) {
    $table->index(['provider_name', 'request_model']);
    $table->index('created_at');
    $table->index(['status_code', 'created_at']);
});

Schema::table('mindwave_traces', function (Blueprint $table) {
    $table->index(['status', 'created_at']);
    $table->index('estimated_cost');
});

Use Chunking for Large Datasets

php
// Good - processes in chunks
Trace::where('created_at', '>=', $date)
    ->chunk(100, function ($traces) {
        foreach ($traces as $trace) {
            // Process each trace
        }
    });

// Bad - loads all into memory
$traces = Trace::where('created_at', '>=', $date)->get();

Eager Load Relationships

php
// Good - one query for traces, one for spans
$traces = Trace::with('spans')->get();

// Bad - N+1 query problem
$traces = Trace::all();
foreach ($traces as $trace) {
    $spans = $trace->spans; // Separate query for each trace
}

Use selectRaw for Aggregations

php
// Good - database does aggregation
$stats = Span::selectRaw('
    AVG(duration) as avg,
    COUNT(*) as count
')->first();

// Bad - loads all rows, does aggregation in PHP
$spans = Span::all();
$avg = $spans->avg('duration');

Data Retention Considerations

Automated Pruning

Use the prune command in a scheduled task:

php
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // Prune traces older than 30 days daily
    $schedule->command('mindwave:prune-traces --days=30')
        ->daily()
        ->at('02:00');
}

Archive Before Pruning

bash
# Export traces before pruning
php artisan mindwave:export-traces \
    --since="3 months ago" \
    --until="1 month ago" \
    --format=ndjson \
    --output=archive-$(date +%Y%m).ndjson

# Then prune
php artisan mindwave:prune-traces --days=30

Selective Retention

Keep important traces longer:

php
// Custom pruning logic
$cutoffDate = Carbon::now()->subDays(30);

// Delete cheap, successful traces
Trace::where('created_at', '<', $cutoffDate)
    ->where('status', 'ok')
    ->where('estimated_cost', '<', 0.01)
    ->delete();

// Keep expensive or failed traces longer
Trace::where('created_at', '<', Carbon::now()->subDays(90))
    ->where(function ($q) {
        $q->where('estimated_cost', '>=', 0.01)
          ->orWhere('status', 'error');
    })
    ->delete();

Privacy and Security in Queries

Redact Sensitive Data

php
// Don't include message content in exports
$traces = Trace::with(['spans' => function ($query) {
    $query->select([
        'id', 'trace_id', 'span_id', 'name',
        'duration', 'status_code',
        'request_model', 'input_tokens', 'output_tokens'
    ]); // Exclude 'attributes' field with messages
}])->get();

Role-Based Access

php
// Only admins can view cost data
if (!auth()->user()->isAdmin()) {
    $traces = Trace::select([
        'trace_id', 'created_at', 'duration', 'status'
    ])->get(); // Exclude estimated_cost
} else {
    $traces = Trace::all();
}

Audit Query Access

php
// Log who queries traces
Event::listen('eloquent.retrieved: Mindwave\Mindwave\Observability\Models\Trace', function ($model) {
    if (auth()->check()) {
        Log::info('Trace accessed', [
            'user_id' => auth()->id(),
            'trace_id' => $model->trace_id,
        ]);
    }
});

Advanced Techniques

Correlating Traces with Logs

Add trace context to your Laravel logs:

php
use Mindwave\Mindwave\Observability\Tracing\TracerManager;
use Illuminate\Support\Facades\Log;

$tracer = app(TracerManager::class);
$span = $tracer->getCurrentSpan();

if ($span) {
    $context = $span->getContext();

    Log::info('Processing user request', [
        'trace_id' => $context->getTraceId(),
        'span_id' => $context->getSpanId(),
        'user_id' => auth()->id(),
    ]);
}

Then query logs by trace ID:

bash
# Find all logs for a trace
grep "trace_id.*abc123" storage/logs/laravel.log

Or in your log aggregation platform:

trace_id:"abc123"

Trace Sampling Strategies

Sample Based on Cost

php
// In your service provider
use Mindwave\Mindwave\Observability\Events\LlmRequestStarted;

Event::listen(LlmRequestStarted::class, function ($event) {
    // Don't trace cheap models in production
    if (app()->environment('production')) {
        $cheapModels = ['gpt-3.5-turbo', 'claude-3-haiku'];

        if (in_array($event->model, $cheapModels)) {
            // Reduce sampling for cheap models
            config(['mindwave-tracing.sampler.ratio' => 0.1]); // 10%
        }
    }
});

Sample Based on User

php
// Always trace for specific users (VIPs, testing)
$vipUsers = [1, 2, 3];

if (in_array(auth()->id(), $vipUsers)) {
    config(['mindwave-tracing.sampler.type' => 'always_on']);
} else {
    config(['mindwave-tracing.sampler.ratio' => 0.1]); // 10% for others
}

Custom Attribute Strategies

Add domain-specific attributes for better querying:

php
use Mindwave\Mindwave\Observability\Tracing\TracerManager;

$tracer = app(TracerManager::class);
$span = $tracer->getCurrentSpan();

if ($span) {
    $span->setAttribute('app.user_id', auth()->id());
    $span->setAttribute('app.feature', 'chat_bot');
    $span->setAttribute('app.customer_tier', auth()->user()->tier);
    $span->setAttribute('app.session_id', session()->getId());
}

Then query by these attributes:

php
// Find traces for a specific user
$userTraces = Trace::whereJsonContains('metadata->user_id', $userId)->get();

// In Jaeger, search by tag:
// app.user_id=123
// app.feature=chat_bot
// app.customer_tier=premium

Summary

Querying traces effectively requires understanding:

  1. Trace Structure - Spans, attributes, events, and relationships
  2. Query Tools - Eloquent, Jaeger, Honeycomb, Zipkin
  3. Use Cases - Performance, cost, debugging, analytics
  4. Best Practices - Indexing, retention, privacy, sampling

Key Takeaways:

  • Database queries provide the most flexibility for custom analytics
  • Jaeger excels at trace visualization and distributed tracing
  • Honeycomb offers powerful aggregations and real-time monitoring
  • Artisan commands simplify common operations
  • Custom attributes make traces more queryable for your domain

With these tools and techniques, you can gain deep insights into your LLM operations, optimize costs, improve performance, and maintain production quality.


Related Documentation:

External Resources: