Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
--health-retries=5

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down
64 changes: 61 additions & 3 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[English](README.md)

CakePHP 5 アプリケーションに OpenTelemetry 計装を追加するプラグイン。`ext-opentelemetry` の `zend_observer` フックを利用し、コード変更なしで Controller / Table のスパンを自動生成する
CakePHP 5 アプリケーションに OpenTelemetry instrumentation を追加するプラグインです。`ext-opentelemetry` の `zend_observer` フックを利用し、コード変更なしで Controller / Table のスパンを自動生成します

## 要件

Expand All @@ -15,7 +15,7 @@ CakePHP 5 アプリケーションに OpenTelemetry 計装を追加するプラ
## インストール

```bash
composer require kaz29/otel-instrumentation
composer require kaz29/cakephp-otel-plugin
```

`config/bootstrap.php` または `Application::bootstrap()` でプラグインを読み込む:
Expand All @@ -24,7 +24,7 @@ composer require kaz29/otel-instrumentation
$this->addPlugin('OtelInstrumentation');
```

## 計装対象
## Instrumentation 対象

| 対象 | スパン名の例 |
|---|---|
Expand All @@ -33,6 +33,64 @@ $this->addPlugin('OtelInstrumentation');
| `Table::save` | `Users.save` |
| `Table::delete` | `Users.delete` |

## カスタム Instrumentation

任意のクラス・メソッドにフックを登録してスパンを自動生成できます。内部では組み込みの Controller / Table 計装と同じ `\OpenTelemetry\Instrumentation\hook()` を使用しています。

### Configure で登録(シンプル)

```php
// config/bootstrap.php または config/app_local.php
use Cake\Core\Configure;
use OpenTelemetry\API\Trace\SpanKind;

Configure::write('OtelInstrumentation.hooks', [
// 最小構成 — スパン名は "App\Service\PaymentService::charge" が自動生成
['class' => \App\Service\PaymentService::class, 'method' => 'charge'],

// オプション付き
[
'class' => \App\Service\PaymentService::class,
'method' => 'refund',
'spanName' => 'payment.refund',
'kind' => SpanKind::KIND_CLIENT,
'attributes' => ['payment.provider' => 'stripe'],
],
]);
```

### 静的メソッドで登録(上級)

動的な属性コールバックが必要な場合は `CustomInstrumentation::register()` を使います:

```php
// Application::bootstrap() 内、$this->addPlugin('OtelInstrumentation') の前に記述
use OtelInstrumentation\Instrumentation\CustomInstrumentation;
use OpenTelemetry\API\Trace\SpanKind;

CustomInstrumentation::register(
\App\Service\PaymentService::class,
'charge',
spanName: 'payment.charge',
kind: SpanKind::KIND_CLIENT,
attributes: ['payment.provider' => 'stripe'],
attributeCallback: fn($instance, $params, $class, $function) => [
'payment.amount' => $params[0] ?? null,
],
);
```

### オプション一覧

| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
| `class` | `string` | (必須) | 対象クラスの完全修飾名 |
| `method` | `string` | (必須) | フック対象のメソッド名 |
| `spanName` | `string\|null` | `FQCN::method` | スパン名のオーバーライド |
| `kind` | `int` | `KIND_INTERNAL` | SpanKind 定数 |
| `attributes` | `array` | `[]` | 静的なスパン属性 |
| `attributeCallback` | `Closure\|null` | `null` | `fn($instance, $params, $class, $function): array` |

## 環境変数

```bash
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A CakePHP 5 plugin that adds OpenTelemetry instrumentation to your application.
## Installation

```bash
composer require kaz29/otel-instrumentation
composer require kaz29/cakephp-otel-plugin
```

Load the plugin in `config/bootstrap.php` or `Application::bootstrap()`:
Expand All @@ -33,6 +33,64 @@ $this->addPlugin('OtelInstrumentation');
| `Table::save` | `Users.save` |
| `Table::delete` | `Users.delete` |

## Custom Instrumentation

You can instrument any class method by registering custom hooks. The plugin uses the same `\OpenTelemetry\Instrumentation\hook()` mechanism as the built-in Controller/Table instrumentation.

### Via Configure (simple)

```php
// config/bootstrap.php or config/app_local.php
use Cake\Core\Configure;
use OpenTelemetry\API\Trace\SpanKind;

Configure::write('OtelInstrumentation.hooks', [
// Minimal — span name auto-generated as "App\Service\PaymentService::charge"
['class' => \App\Service\PaymentService::class, 'method' => 'charge'],

// With options
[
'class' => \App\Service\PaymentService::class,
'method' => 'refund',
'spanName' => 'payment.refund',
'kind' => SpanKind::KIND_CLIENT,
'attributes' => ['payment.provider' => 'stripe'],
],
]);
```

### Via static registration (advanced)

Use `CustomInstrumentation::register()` when you need dynamic attributes via callback:

```php
// In Application::bootstrap(), before $this->addPlugin('OtelInstrumentation')
use OtelInstrumentation\Instrumentation\CustomInstrumentation;
use OpenTelemetry\API\Trace\SpanKind;

CustomInstrumentation::register(
\App\Service\PaymentService::class,
'charge',
spanName: 'payment.charge',
kind: SpanKind::KIND_CLIENT,
attributes: ['payment.provider' => 'stripe'],
attributeCallback: fn($instance, $params, $class, $function) => [
'payment.amount' => $params[0] ?? null,
],
);
```

### Options

| Option | Type | Default | Description |
|---|---|---|---|
| `class` | `string` | (required) | Fully qualified class name |
| `method` | `string` | (required) | Method name to hook |
| `spanName` | `string\|null` | `FQCN::method` | Custom span name |
| `kind` | `int` | `KIND_INTERNAL` | SpanKind constant |
| `attributes` | `array` | `[]` | Static span attributes |
| `attributeCallback` | `Closure\|null` | `null` | `fn($instance, $params, $class, $function): array` |

## Environment Variables

```bash
Expand Down
212 changes: 212 additions & 0 deletions src/Instrumentation/CustomInstrumentation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);

namespace OtelInstrumentation\Instrumentation;

use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;

final class CustomInstrumentation
{
/** @var HookDefinition[] */
private static array $definitions = [];

/** @var array<string, true> */
private static array $registeredKeys = [];

private static bool $applied = false;

/**
* Register a hook for a class method.
*
* @param class-string $class
* @param string $method
* @param string|null $spanName Override span name (default: FQCN::method)
* @param int $kind SpanKind constant (default: KIND_INTERNAL)
* @param array<string, mixed> $attributes Static attributes
* @param (\Closure(object|null, array, string, string): array<string, mixed>)|null $attributeCallback
*/
public static function register(
string $class,
string $method,
?string $spanName = null,
int $kind = SpanKind::KIND_INTERNAL,
array $attributes = [],
?\Closure $attributeCallback = null,
): void {
$definition = new HookDefinition(
class: $class,
method: $method,
spanName: $spanName,
kind: $kind,
attributes: $attributes,
attributeCallback: $attributeCallback,
);

if (!self::addDefinition($definition)) {
return;
}

if (self::$applied) {
self::applyDefinition($definition);
}
}

/**
* Register from a HookDefinition directly.
*/
public static function add(HookDefinition $definition): void
{
if (!self::addDefinition($definition)) {
return;
}

if (self::$applied) {
self::applyDefinition($definition);
}
}

/**
* Load hook definitions from Configure-style array format.
*
* @param array<array{class: class-string, method: string, spanName?: string, kind?: int, attributes?: array<string, mixed>, attributeCallback?: \Closure}> $configs
*/
public static function loadFromConfig(array $configs): void
{
foreach ($configs as $i => $config) {
if (!isset($config['class']) || !isset($config['method'])) {
throw new \InvalidArgumentException(
sprintf('OtelInstrumentation.hooks[%d] must have "class" and "method" keys.', $i)
);
}

$definition = new HookDefinition(
class: $config['class'],
method: $config['method'],
spanName: $config['spanName'] ?? null,
kind: $config['kind'] ?? SpanKind::KIND_INTERNAL,
attributes: $config['attributes'] ?? [],
attributeCallback: $config['attributeCallback'] ?? null,
);

if (!self::addDefinition($definition)) {
continue;
}

if (self::$applied) {
self::applyDefinition($definition);
}
}
}

/**
* Apply all registered hooks via \OpenTelemetry\Instrumentation\hook().
* Called during Plugin::bootstrap(). Definitions registered after apply()
* will be hooked immediately.
*/
public static function apply(): void
{
if (self::$applied) {
return;
}
self::$applied = true;

Comment on lines +110 to +116
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CustomInstrumentation::apply() becomes a one-shot toggle (self::$applied). Because Plugin::bootstrap() calls apply() unconditionally, any later CustomInstrumentation::register()/add()/loadFromConfig() calls will never take effect. Consider either (a) making register/add/loadFromConfig immediately hook definitions when already applied, or (b) tracking which definitions have been hooked and allowing apply() to be called multiple times to apply newly registered hooks.

Copilot uses AI. Check for mistakes.
foreach (self::$definitions as $definition) {
self::applyDefinition($definition);
}
}

/**
* Add a definition if not already registered for the same class/method.
*
* @return bool true if added, false if duplicate
*/
private static function addDefinition(HookDefinition $definition): bool
{
$key = $definition->class . '::' . $definition->method;
if (isset(self::$registeredKeys[$key])) {
return false;
}

self::$registeredKeys[$key] = true;
self::$definitions[] = $definition;

return true;
}

private static function applyDefinition(HookDefinition $def): void
{
$instrumentation = new CachedInstrumentation('otel-instrumentation.cakephp.custom');

\OpenTelemetry\Instrumentation\hook(
class: $def->class,
function: $def->method,
pre: static function (
mixed $instance,
array $params,
string $class,
string $function,
?string $filename,
?int $lineno,
) use ($instrumentation, $def): void {
$spanBuilder = $instrumentation->tracer()
->spanBuilder($def->spanName ?? ($class . '::' . $function))
->setSpanKind($def->kind);

foreach ($def->attributes as $key => $value) {
$spanBuilder->setAttribute($key, $value);
}

if ($def->attributeCallback !== null) {
try {
$dynamicAttrs = ($def->attributeCallback)($instance, $params, $class, $function);
foreach ($dynamicAttrs as $key => $value) {
$spanBuilder->setAttribute($key, $value);
}
} catch (\Throwable) {
// Instrumentation must not break application code
}
}

$span = $spanBuilder->startSpan();
Context::storage()->attach($span->storeInContext(Context::getCurrent()));
},
post: static function (
mixed $instance,
array $params,
mixed $returnValue,
?\Throwable $exception,
): void {
$scope = Context::storage()->scope();
if ($scope === null) {
return;
}

$scope->detach();
$span = Span::fromContext($scope->context());

if ($exception !== null) {
$span->recordException($exception);
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
} else {
$span->setStatus(StatusCode::STATUS_OK);
}

$span->end();
},
);
}

/**
* Reset state (for testing).
*/
public static function reset(): void
{
self::$definitions = [];
self::$registeredKeys = [];
self::$applied = false;
}
}
Loading