diff --git a/src/Hooks/Illuminate/Contracts/Http/Kernel.php b/src/Hooks/Illuminate/Contracts/Http/Kernel.php index 2b59751..b0c0eb9 100644 --- a/src/Hooks/Illuminate/Contracts/Http/Kernel.php +++ b/src/Hooks/Illuminate/Contracts/Http/Kernel.php @@ -61,11 +61,12 @@ protected function hookHandle(): bool ->setAttribute(TraceAttributes::HTTP_REQUEST_BODY_SIZE, $request->header('Content-Length')) ->setAttribute(TraceAttributes::URL_SCHEME, $request->getScheme()) ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $request->getProtocolVersion()) - ->setAttribute(TraceAttributes::NETWORK_PEER_ADDRESS, $request->ip()) + ->setAttribute(TraceAttributes::NETWORK_PEER_ADDRESS, $request->server('REMOTE_ADDR')) ->setAttribute(TraceAttributes::URL_PATH, $this->httpTarget($request)) ->setAttribute(TraceAttributes::SERVER_ADDRESS, $this->httpHostName($request)) ->setAttribute(TraceAttributes::SERVER_PORT, $request->getPort()) ->setAttribute(TraceAttributes::CLIENT_PORT, $request->server('REMOTE_PORT')) + ->setAttribute(TraceAttributes::CLIENT_ADDRESS, $request->ip()) ->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $request->userAgent()) ->startSpan(); $request->attributes->set(SpanInterface::class, $span); diff --git a/src/Hooks/Illuminate/Database/Eloquent/Model.php b/src/Hooks/Illuminate/Database/Eloquent/Model.php new file mode 100644 index 0000000..70de1bf --- /dev/null +++ b/src/Hooks/Illuminate/Database/Eloquent/Model.php @@ -0,0 +1,252 @@ +hookFind(); + $this->hookPerformInsert(); + $this->hookPerformUpdate(); + $this->hookDelete(); + $this->hookGetModels(); + $this->hookDestroy(); + $this->hookRefresh(); + } + + private function hookFind(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + \Illuminate\Database\Eloquent\Builder::class, + 'find', + pre: function ($builder, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $model = $builder->getModel(); + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::find') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'find'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function ($builder, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookPerformUpdate(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + EloquentModel::class, + 'performUpdate', + pre: function (EloquentModel $model, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::update') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'update'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (EloquentModel $model, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookPerformInsert(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + EloquentModel::class, + 'performInsert', + pre: function (EloquentModel $model, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::create') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'create'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (EloquentModel $model, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookDelete(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + EloquentModel::class, + 'delete', + pre: function (EloquentModel $model, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::delete') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'delete'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (EloquentModel $model, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookGetModels(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + \Illuminate\Database\Eloquent\Builder::class, + 'getModels', + pre: function ($builder, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $model = $builder->getModel(); + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::get') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'get') + ->setAttribute('db.statement', $builder->getQuery()->toSql()); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function ($builder, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookDestroy(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + EloquentModel::class, + 'destroy', + pre: function ($model, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::destroy') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'destroy'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function ($model, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } + + private function hookRefresh(): void + { + /** @psalm-suppress UnusedFunctionCall */ + hook( + EloquentModel::class, + 'refresh', + pre: function (EloquentModel $model, array $params, string $class, string $function, ?string $filename, ?int $lineno) { + $builder = $this->instrumentation + ->tracer() + ->spanBuilder($model::class . '::refresh') + ->setSpanKind(SpanKind::KIND_INTERNAL) + ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno) + ->setAttribute('laravel.eloquent.model', $model::class) + ->setAttribute('laravel.eloquent.table', $model->getTable()) + ->setAttribute('laravel.eloquent.operation', 'refresh'); + + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + + return $params; + }, + post: function (EloquentModel $model, array $params, $result, ?Throwable $exception) { + $this->endSpan($exception); + } + ); + } +} diff --git a/src/LaravelInstrumentation.php b/src/LaravelInstrumentation.php index df54324..efd32ed 100644 --- a/src/LaravelInstrumentation.php +++ b/src/LaravelInstrumentation.php @@ -29,6 +29,7 @@ public static function register(): void Hooks\Illuminate\Queue\SyncQueue::hook($instrumentation); Hooks\Illuminate\Queue\Queue::hook($instrumentation); Hooks\Illuminate\Queue\Worker::hook($instrumentation); + Hooks\Illuminate\Database\Eloquent\Model::hook($instrumentation); } public static function shouldTraceCli(): bool diff --git a/tests/Fixtures/Models/TestModel.php b/tests/Fixtures/Models/TestModel.php new file mode 100644 index 0000000..fd9f7ea --- /dev/null +++ b/tests/Fixtures/Models/TestModel.php @@ -0,0 +1,16 @@ +set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + public function test_request_response(): void { $this->router()->get('/', fn () => null); @@ -73,6 +85,107 @@ public function test_cache_log_db(): void $this->assertSame(json_encode(['test' => true]), $logRecord->getAttributes()->toArray()['context']); } + public function test_eloquent_operations(): void + { + // Assert storage is empty before interacting with the database + $this->assertCount(0, $this->storage); + + // Create the test_models table + DB::statement('CREATE TABLE IF NOT EXISTS test_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + created_at DATETIME, + updated_at DATETIME + )'); + + $this->router()->get('/eloquent', function () { + try { + // Test create + $created = TestModel::create(['name' => 'test']); + + // Test find + $found = TestModel::find($created->id); + + // Test update + $found->update(['name' => 'updated']); + + // Test delete + $found->delete(); + + return response()->json(['status' => 'ok']); + } catch (\Exception $e) { + return response()->json([ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ], 500); + } + }); + + $response = $this->call('GET', '/eloquent'); + if ($response->status() !== 200) { + $this->fail('Request failed: ' . $response->content()); + } + $this->assertEquals(200, $response->status()); + + // Verify spans for each Eloquent operation + /** @var array $spans */ + $spans = array_values(array_filter( + iterator_to_array($this->storage), + fn ($item) => $item instanceof \OpenTelemetry\SDK\Trace\ImmutableSpan + )); + + // Filter out SQL spans and keep only Eloquent spans + $eloquentSpans = array_values(array_filter( + $spans, + fn ($span) => str_contains($span->getName(), '::') + )); + + // Sort spans by operation type to ensure consistent order + usort($eloquentSpans, function ($a, $b) { + $operations = ['create' => 0, 'find' => 1, 'update' => 2, 'delete' => 3]; + $aOp = $a->getAttributes()->get('laravel.eloquent.operation'); + $bOp = $b->getAttributes()->get('laravel.eloquent.operation'); + + return ($operations[$aOp] ?? 999) <=> ($operations[$bOp] ?? 999); + }); + + // Create span + $createSpan = array_values(array_filter($eloquentSpans, function ($span) { + return $span->getAttributes()->get('laravel.eloquent.operation') === 'create'; + }))[0]; + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel::create', $createSpan->getName()); + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel', $createSpan->getAttributes()->get('laravel.eloquent.model')); + $this->assertSame('test_models', $createSpan->getAttributes()->get('laravel.eloquent.table')); + $this->assertSame('create', $createSpan->getAttributes()->get('laravel.eloquent.operation')); + + // Find span + $findSpan = array_values(array_filter($eloquentSpans, function ($span) { + return $span->getAttributes()->get('laravel.eloquent.operation') === 'find'; + }))[0]; + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel::find', $findSpan->getName()); + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel', $findSpan->getAttributes()->get('laravel.eloquent.model')); + $this->assertSame('test_models', $findSpan->getAttributes()->get('laravel.eloquent.table')); + $this->assertSame('find', $findSpan->getAttributes()->get('laravel.eloquent.operation')); + + // Update span + $updateSpan = array_values(array_filter($eloquentSpans, function ($span) { + return $span->getAttributes()->get('laravel.eloquent.operation') === 'update'; + }))[0]; + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel::update', $updateSpan->getName()); + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel', $updateSpan->getAttributes()->get('laravel.eloquent.model')); + $this->assertSame('test_models', $updateSpan->getAttributes()->get('laravel.eloquent.table')); + $this->assertSame('update', $updateSpan->getAttributes()->get('laravel.eloquent.operation')); + + // Delete span + $deleteSpan = array_values(array_filter($eloquentSpans, function ($span) { + return $span->getAttributes()->get('laravel.eloquent.operation') === 'delete'; + }))[0]; + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel::delete', $deleteSpan->getName()); + $this->assertSame('OpenTelemetry\Tests\Contrib\Instrumentation\Laravel\Fixtures\Models\TestModel', $deleteSpan->getAttributes()->get('laravel.eloquent.model')); + $this->assertSame('test_models', $deleteSpan->getAttributes()->get('laravel.eloquent.table')); + $this->assertSame('delete', $deleteSpan->getAttributes()->get('laravel.eloquent.operation')); + } + public function test_low_cardinality_route_span_name(): void { $this->router()->get('/hello/{name}', fn () => null)->name('hello-name');