From 6de0be4767896a4523ad733a2ac0c15181ea93e0 Mon Sep 17 00:00:00 2001 From: Cedric Ziel Date: Sat, 29 Mar 2025 12:52:46 +0100 Subject: [PATCH 1/4] feat: track Eloquent ORM --- .../Illuminate/Database/Eloquent/Model.php | 252 ++++++++++++++++++ .../Laravel/src/LaravelInstrumentation.php | 1 + .../LaravelInstrumentationTest.php | 127 +++++++++ 3 files changed, 380 insertions(+) create mode 100644 src/Instrumentation/Laravel/src/Hooks/Illuminate/Database/Eloquent/Model.php diff --git a/src/Instrumentation/Laravel/src/Hooks/Illuminate/Database/Eloquent/Model.php b/src/Instrumentation/Laravel/src/Hooks/Illuminate/Database/Eloquent/Model.php new file mode 100644 index 000000000..70de1bfa3 --- /dev/null +++ b/src/Instrumentation/Laravel/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/Instrumentation/Laravel/src/LaravelInstrumentation.php b/src/Instrumentation/Laravel/src/LaravelInstrumentation.php index df543246e..efd32ed99 100644 --- a/src/Instrumentation/Laravel/src/LaravelInstrumentation.php +++ b/src/Instrumentation/Laravel/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/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php index 1471b7f4c..1c39c02db 100644 --- a/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php +++ b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php @@ -13,6 +13,17 @@ /** @psalm-suppress UnusedClass */ class LaravelInstrumentationTest extends TestCase { + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + $app['config']->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 +84,122 @@ public function test_cache_log_db(): void $this->assertSame(json_encode(['test' => true]), $logRecord->getAttributes()->toArray()['context']); } + public function test_eloquent_operations(): void + { + /** @var class-string<\Illuminate\Database\Eloquent\Model> */ + $modelClass = eval(' + if (!class_exists("TestModel")) { + class TestModel extends \Illuminate\Database\Eloquent\Model + { + protected $table = "test_models"; + protected $fillable = ["name"]; + } + } + return TestModel::class; + '); + + // 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 () use ($modelClass) { + try { + /** @var \Illuminate\Database\Eloquent\Model $model */ + $model = new $modelClass(); + + // Test create + $created = $modelClass::create(['name' => 'test']); + + // Test find + $found = $modelClass::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('TestModel::create', $createSpan->getName()); + $this->assertSame('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('TestModel::find', $findSpan->getName()); + $this->assertSame('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('TestModel::update', $updateSpan->getName()); + $this->assertSame('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('TestModel::delete', $deleteSpan->getName()); + $this->assertSame('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'); From 28d1968b34b9daa9fc6e37ae394968bb59d9c41b Mon Sep 17 00:00:00 2001 From: Cedric Ziel Date: Sun, 30 Mar 2025 18:03:31 +0200 Subject: [PATCH 2/4] Update src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php Co-authored-by: Chris Lightfoot-Wild --- .../Integration/LaravelInstrumentationTest.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php index 1c39c02db..aad0ba4d5 100644 --- a/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php +++ b/src/Instrumentation/Laravel/tests/Integration/LaravelInstrumentationTest.php @@ -87,16 +87,10 @@ public function test_cache_log_db(): void public function test_eloquent_operations(): void { /** @var class-string<\Illuminate\Database\Eloquent\Model> */ - $modelClass = eval(' - if (!class_exists("TestModel")) { - class TestModel extends \Illuminate\Database\Eloquent\Model - { - protected $table = "test_models"; - protected $fillable = ["name"]; - } - } - return TestModel::class; - '); + $model = new class() extends \Illuminate\Database\Eloquent\Model { + protected $table = 'test_models'; + protected $fillable = ['name']; + }; // Assert storage is empty before interacting with the database $this->assertCount(0, $this->storage); From 321140bd7ed47e4992e31b1bd65c27d12eb25950 Mon Sep 17 00:00:00 2001 From: Cedric Ziel Date: Mon, 31 Mar 2025 07:34:02 +0200 Subject: [PATCH 3/4] fix: move eval to fixture --- .../tests/Fixtures/Models/TestModel.php | 13 ++++++++ .../LaravelInstrumentationTest.php | 32 +++++++------------ 2 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php diff --git a/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php b/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php new file mode 100644 index 000000000..0eee47cf4 --- /dev/null +++ b/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php @@ -0,0 +1,13 @@ + */ - $model = new class() extends \Illuminate\Database\Eloquent\Model { - protected $table = 'test_models'; - protected $fillable = ['name']; - }; - // Assert storage is empty before interacting with the database $this->assertCount(0, $this->storage); @@ -103,16 +98,13 @@ public function test_eloquent_operations(): void updated_at DATETIME )'); - $this->router()->get('/eloquent', function () use ($modelClass) { + $this->router()->get('/eloquent', function () { try { - /** @var \Illuminate\Database\Eloquent\Model $model */ - $model = new $modelClass(); - // Test create - $created = $modelClass::create(['name' => 'test']); + $created = TestModel::create(['name' => 'test']); // Test find - $found = $modelClass::find($created->id); + $found = TestModel::find($created->id); // Test update $found->update(['name' => 'updated']); @@ -161,8 +153,8 @@ public function test_eloquent_operations(): void $createSpan = array_values(array_filter($eloquentSpans, function ($span) { return $span->getAttributes()->get('laravel.eloquent.operation') === 'create'; }))[0]; - $this->assertSame('TestModel::create', $createSpan->getName()); - $this->assertSame('TestModel', $createSpan->getAttributes()->get('laravel.eloquent.model')); + $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')); @@ -170,8 +162,8 @@ public function test_eloquent_operations(): void $findSpan = array_values(array_filter($eloquentSpans, function ($span) { return $span->getAttributes()->get('laravel.eloquent.operation') === 'find'; }))[0]; - $this->assertSame('TestModel::find', $findSpan->getName()); - $this->assertSame('TestModel', $findSpan->getAttributes()->get('laravel.eloquent.model')); + $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')); @@ -179,8 +171,8 @@ public function test_eloquent_operations(): void $updateSpan = array_values(array_filter($eloquentSpans, function ($span) { return $span->getAttributes()->get('laravel.eloquent.operation') === 'update'; }))[0]; - $this->assertSame('TestModel::update', $updateSpan->getName()); - $this->assertSame('TestModel', $updateSpan->getAttributes()->get('laravel.eloquent.model')); + $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')); @@ -188,8 +180,8 @@ public function test_eloquent_operations(): void $deleteSpan = array_values(array_filter($eloquentSpans, function ($span) { return $span->getAttributes()->get('laravel.eloquent.operation') === 'delete'; }))[0]; - $this->assertSame('TestModel::delete', $deleteSpan->getName()); - $this->assertSame('TestModel', $deleteSpan->getAttributes()->get('laravel.eloquent.model')); + $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')); } From a793e314143c92dbe778bf0a4c379aedb7b0c2b3 Mon Sep 17 00:00:00 2001 From: Cedric Ziel Date: Mon, 31 Mar 2025 08:46:25 +0200 Subject: [PATCH 4/4] chore: ignore false positive for unused class --- .../Laravel/tests/Fixtures/Models/TestModel.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php b/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php index 0eee47cf4..fd9f7ea82 100644 --- a/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php +++ b/src/Instrumentation/Laravel/tests/Fixtures/Models/TestModel.php @@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Model; +/** + * @psalm-suppress UnusedClass + */ class TestModel extends Model { protected $table = 'test_models';