labelgrupnetworks/opentelemetry-sdk

Grafana Loki Logging Package

Maintainers

Package info

github.com/labelgrupnetworks/opentelemetry-sdk

pkg:composer/labelgrupnetworks/opentelemetry-sdk

Statistics

Installs: 54

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-05-20 10:59 UTC

This package is auto-updated.

Last update: 2026-05-20 11:04:32 UTC


README

Laravel package for structured logging to Grafana Loki with OpenTelemetry tracing support.

Every log entry is enriched with a trace_id and span_id so HTTP requests, CLI commands, queued jobs, and exceptions can be correlated in Loki/Grafana.

📦 Installation

composer require labelgrupnetworks/opentelemetry-sdk

Publish the config file:

php artisan vendor:publish --provider="LokiLogging\LokiLoggingServiceProvider"

⚙️ Configuration

config/loki-logging.php:

return [
    // Loki push API endpoint
    'url' => env('LOG_LOKI_URL'),

    // Service name attached to every log entry
    'service_name' => env('SERVICE_NAME'),

    'classes' => [
        // Optional: class that adds custom fields to every log entry
        // Must implement LokiLogging\Core\Context\ContextInterface
        'context' => null,

        // Optional: custom Monolog formatter. Must extend LokiLogging\Core\Formatter\Formatter.
        // When null, the built-in OpenTelemetryFormatter is used.
        'formatter' => null,
    ],

    // Exception class → ExceptionLogInterface class mappings.
    // Matched via instanceof (first match wins).
    // Falls back to the built-in ExceptionLog if no match is found.
    'exceptions_logs' => [
        // \App\Exceptions\MyException::class => \App\Exceptions\Loki\MyExceptionLog::class,
    ],
];

Register the service provider and configure the Loki channel in config/logging.php:

// config/app.php
'providers' => [
    LokiLogging\LokiLoggingServiceProvider::class,
],

// config/logging.php
'channels' => [
    'loki' => [
        'driver' => 'monolog',
        'level' => env('LOG_LEVEL', 'debug'),
        'handler' => \LokiLogging\Core\Loki\LokiHandler::class
    ],
],

Set LOG_CHANNEL=loki in your .env.

Migrating from the old monolog driver config? The previous format (driver: monolog, handler, formatter: default, processors) still works. The new loki driver is the recommended approach going forward.

🌐 HTTP Request Tracing

Register LokiTraceMiddleware in app/Http/Kernel.php:

protected $middleware = [
    \LokiLogging\Http\Middleware\LokiTraceMiddleware::class,
];

For every HTTP request this middleware:

  1. Extracts an incoming traceparent header (if present) to continue a distributed trace.
  2. Opens an OpenTelemetry span (HTTP METHOD /path).
  3. On response, logs the request/response to Loki with trace_id, span_id, method, path, status code, and any baggage fields.
  4. Marks the span ERROR on 5xx responses or unhandled exceptions.

Propagating trace context from an external caller

The middleware uses the W3C TraceContext standard. To continue a distributed trace, the caller must send a traceparent header:

traceparent: 00-{traceId}-{parentSpanId}-{traceFlags}
Part Length Description
00 2 chars Version (always 00)
traceId 32 hex chars Identifies the whole distributed trace
parentSpanId 16 hex chars The caller's current span
traceFlags 2 hex chars 01 = sampled, 00 = not sampled

curl:

curl -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
     https://your-app.com/api/orders

From another service that also uses this package:

use LokiLogging\Helpers\LokiTrace;

$http->withHeader(
    'traceparent',
    sprintf('00-%s-%s-01', LokiTrace::traceId(), LokiTrace::spanId())
);

🖥️ CLI Command Tracing

Handled automatically by CommandTracingManager (wired in the service provider). Each php artisan invocation opens a span for the duration of the command and logs the result (success/failure) to Loki.

Daemon commands (queue:work, horizon, etc.) are excluded from span creation to avoid infinite spans.

🔄 Queue Job Tracing

Handled automatically by JobTracingManager. Each job dispatch injects the current trace_id into the queue payload so the job can be correlated with the HTTP request or CLI command that dispatched it. A span is opened when the job starts processing and closed (with the appropriate status) when it finishes, fails, or throws.

🚨 Exception Logging

How it works

Exceptions are logged to Loki from your app/Exceptions/Handler.php. The full flow is:

Exception thrown
  → Handler::reportable()
  → LokiLoggingExceptionLogger::log($exception)
  → LokiExceptionLog::exception($exception)
  → config('loki-logging.exceptions_logs') resolved via instanceof
      → match found  → CustomExceptionLog::severity() + ::data()
      → no match     → ExceptionLog::severity() + ExceptionData::fromException()
  → Log::error/warning($message, ['exception' => $exceptionData])
  → Loki

Handler setup

Wire up the logger once in Handler::register():

use LokiLogging\Exception\LokiLoggingExceptionLogger;

public function register(): void
{
    $this->reportable(function (Throwable $exception) {
        // Exclude exceptions that should not be logged
        if ($exception instanceof OAuthServerException) {
            return false;
        }

        LokiLoggingExceptionLogger::log($exception);

        return false; // prevent Laravel's default logging
    });
}

return false stops Laravel's default log chain so exceptions are not double-logged.

Default behaviour

Any exception not listed in exceptions_logs is handled by the built-in ExceptionLog:

Exception code Severity
>= 500 ERROR
2xx DEBUG
everything else WARNING

The data sent to Loki contains: class, code, file:line, and the first 5 frames of the stack trace.

Custom exception mapping

To enrich specific exception types, create a class implementing ExceptionLogInterface:

namespace App\Exceptions\Loki;

use LokiLogging\Core\Data\ExceptionData;use LokiLogging\Core\Enums\Severity;use LokiLogging\Core\Exceptions\ExceptionLog;use LokiLogging\Core\Exceptions\ExceptionLogInterface;

class MyExceptionLog implements ExceptionLogInterface
{
    public static function data(\Throwable $exception): ExceptionData
    {
        return ExceptionData::fromException($exception)
            ->withErrorCode($exception->errorCode)          // adds 'error_code' field
            ->withExtraData(['extra' => $exception->extra]); // adds 'extra_data' field
    }

    public static function severity(\Throwable $exception): Severity
    {
        // Delegate to the default HTTP-code-based logic, or hardcode a level
        return ExceptionLog::severity($exception);
    }
}

Register it in config/loki-logging.php:

'exceptions_logs' => [
    \App\Exceptions\MyException::class => \App\Exceptions\Loki\MyExceptionLog::class,
],

Matching is done via instanceof, so a mapping on a base class also covers all its subclasses. The first match in the array wins.

ExceptionData API

ExceptionData::fromException($e) // base data: class, code, message, file, trace
    ->withErrorCode(string $code) // add an application-level error code
    ->withExtraData(array $extra) // merge extra key/value fields
    // Read
    ->getMessage()
    ->getClass()
    ->getCode()
    ->getFile()
    ->getLine()
    ->getErrorCode()
    ->getExtraData()
    ->toArray()           // serialised form sent to Loki

📐 Log entry format (Formatter)

src/Core/Formatter/Formatter.php is the abstract Monolog formatter that serialises every log record into the JSON string pushed to Loki. The concrete implementation is src/Formatter/OpenTelemetryFormatter.php. Understanding its output is useful when querying Loki/Grafana.

Custom formatter

If you need a different output format, create a class extending LokiLogging\Core\Formatter\Formatter and implement logData():

namespace App\Logging;

use LokiLogging\Core\Formatter\Formatter;
use Monolog\LogRecord;

class MyLokiFormatter extends Formatter
{
    protected function logData(LogRecord $record, array $context): array
    {
        return [
            'message' => $record->message,
            'context' => $context,
            // ...your custom fields
        ];
    }
}

Register it in config/loki-logging.php:

'classes' => [
    'formatter' => \App\Logging\MyLokiFormatter::class,
],

You can also override the formatter per-channel in config/logging.php (takes precedence over classes.formatter):

'loki' => [
    'driver' => 'loki',
    'level' => env('LOG_LEVEL', 'debug'),
    'formatter' => \App\Logging\MyLokiFormatter::class,
],

Note: the getContext(), getErrorCode(), getContextData(), and SEVERITY_MAP helpers are available to subclasses via protected visibility.

Output JSON structure

{
    "body": "The log message",
    "context": { ... },
    "env": "production",
    "error_code": "ERR#001",
    "http.method": "POST",
    "http.route": "api/orders",
    "http.status_code": 422,
    "severity": "ERROR",
    "severity_number": 17,
    "service_name": "NAP",
    "span_id": "abc123",
    "trace_id": "def456",
    "trace_flags": 1,
    "type": "http",
    "user": 42
}
Field Source Notes
body LogRecord::$message The string passed to Log::error(...)
context LogRecord::$context merged with context_class See below
env ResourceInfoconfig('app.env')
error_code ExceptionData::getErrorCode() Only present for exception logs
http.method $record->extra (injected by middleware)
http.route $record->extra (injected by middleware)
http.status_code $record->extra (injected by middleware)
severity Monolog level name, uppercased DEBUG, WARNING, ERROR
severity_number OTel severity number scale See table below
service_name ResourceInfoconfig('loki-logging.service_name')
span_id $record->extra (injected by Processor)
trace_id $record->extra (injected by Processor)
trace_flags $record->extra (injected by Processor)
type $record->extra (injected by middleware / tracing managers) http, cli, job, exception, log
user_id auth()->id() null for unauthenticated or CLI

Severity number mapping

Monolog level severity severity_number
DEBUG DEBUG 5
INFO INFO 9
NOTICE NOTICE 10
WARNING WARNING 13
ERROR ERROR 17
CRITICAL CRITICAL 21
ALERT ALERT 22
EMERGENCY EMERGENCY 24

Numbers follow the OpenTelemetry Log Data Model specification.

How context is built

context is the merge of two sources, in this order:

context = [ ...classes.context::context(), ...LogRecord::$context ]
  1. classes.context::context() — global fields added to every entry (e.g. tenant_id, user_id). Configured via config('loki-logging.classes.context'). Returns [] if not set.
  2. LogRecord::$context — the array passed as the second argument to Log::error($msg, $context).

If a value inside $context is an ExceptionData instance (set automatically by LokiLoggingExceptionLogger), it is serialised via ExceptionData::toArray() before being stored:

"context": {
    "tenant_id": "acme",
    "exception": {
        "class": "Domain\\Nap\\Shared\\Exceptions\\UseCaseException",
        "code": 422,
        "file": "app/Http/Controllers/OrderController.php:45",
        "trace": ["...", "..."],
        "error_code": "ERR#001",
        "extra_data": { "report_data": { "order_id": 99 } }
    }
}

How error_code is extracted

error_code is promoted to a top-level field (outside context) so it can be used as a Loki label or for direct filtering. The formatter tries to extract it in this order:

  1. $record->context['exception'] is an ExceptionData → calls ->getErrorCode()
  2. $record->context['exception'] is an array → reads errorCode, error_code, or system_error key
  3. Not found → null (field omitted from the output)

🧩 Custom Context

To append extra fields to every log entry (e.g. tenant ID, user ID), create a class implementing ContextInterface and set it in config:

namespace App\Services\Loki;

use LokiLogging\Core\Context\ContextInterface;

class LokiContext implements ContextInterface
{
    public static function context(): array
    {
        return [
            'user_id' => auth()->id(),
            'tenant_id' => tenant()->id,
        ];
    }
}
// config/loki-logging.php
'classes' => [
    'context' => \App\Services\Loki\LokiContext::class,
],

🔍 Reading trace_id / span_id in your code

use LokiLogging\Helpers\LokiTrace;

LokiTrace::traceId(); // string|null
LokiTrace::spanId(); // string|null
LokiTrace::context(); // ['trace_id' => ..., 'span_id' => ...]

🗂️ Package structure

src/
├── Core/
│   ├── Context/
│   │   └── ContextInterface.php         # Contract for custom context fields
│   ├── Enums/
│   │   ├── Severity.php                 # Log severity levels
│   │   └── Type.php                     # Log entry type (http, cli, job, exception)
│   ├── Exceptions/
│   │   ├── Data/
│   │   │   └── ExceptionData.php        # DTO sent to Loki for exceptions
│   │   ├── ExceptionLog.php             # Default ExceptionLogInterface implementation
│   │   └── ExceptionLogInterface.php    # Contract for custom exception mapping
│   ├── Formatter/
│   │   ├── Formatter.php                # Abstract base formatter — extend to customise output
│   │   └── FormatterInterface.php       # Interface contract for formatters
│   ├── Loki/
│   │   └── LokiHandler.php              # Monolog handler — pushes to Loki API
│   ├── Processor/
│   │   └── Processor.php               # Injects trace_id / span_id into every record
│   └── TracingManager/
│       ├── CommandTracingManager.php    # Span lifecycle for artisan commands
│       └── JobTracingManager.php        # Span lifecycle for queued jobs
├── Exception/
│   ├── LokiLoggingException.php         # Internal package exception
│   └── LokiLoggingExceptionLogger.php   # Entry point for exception logging
├── Formatter/
│   └── OpenTelemetryFormatter.php       # Default formatter — OpenTelemetry JSON output
├── Helpers/
│   ├── LokiExceptionLog.php             # Static helper — resolves and dispatches exception logs
│   └── LokiTrace.php                    # Static helper — reads current trace_id / span_id
├── Http/
│   └── Middleware/
│       └── LokiTraceMiddleware.php      # HTTP request span + response log
├── LokiLoggingConfig.php                # Typed accessors for config/loki-logging.php
└── LokiLoggingServiceProvider.php       # Registers singletons, wires events