labelgrupnetworks / opentelemetry-sdk
Grafana Loki Logging Package
Package info
github.com/labelgrupnetworks/opentelemetry-sdk
pkg:composer/labelgrupnetworks/opentelemetry-sdk
Requires
- php: ^8.2
- laravel/framework: ^10.0|^11.0|^11.0|^12.0|^13.0
- nyholm/psr7: ^1.8
- open-telemetry/sdk: ^1.14
- symfony/http-client: ^7.4
Requires (Dev)
- laravel/pint: ^1.16
- orchestra/testbench: ^8.23
- phpunit/phpunit: ^9.5
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
monologdriver config? The previous format (driver: monolog,handler,formatter: default,processors) still works. The newlokidriver 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:
- Extracts an incoming
traceparentheader (if present) to continue a distributed trace. - Opens an OpenTelemetry span (
HTTP METHOD /path). - On response, logs the request/response to Loki with
trace_id,span_id, method, path, status code, and any baggage fields. - Marks the span
ERRORon 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(), andSEVERITY_MAPhelpers are available to subclasses viaprotectedvisibility.
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 |
ResourceInfo → config('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 |
ResourceInfo → config('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 ]
classes.context::context()— global fields added to every entry (e.g.tenant_id,user_id). Configured viaconfig('loki-logging.classes.context'). Returns[]if not set.LogRecord::$context— the array passed as the second argument toLog::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:
$record->context['exception']is anExceptionData→ calls->getErrorCode()$record->context['exception']is an array → readserrorCode,error_code, orsystem_errorkey- 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