From e25172618be9d552884d5ac758e92b5062660b9e Mon Sep 17 00:00:00 2001 From: Rafael White Date: Thu, 21 May 2026 10:03:16 -0400 Subject: [PATCH 01/13] feat(users): add updateBannerUrl mutation to store banner as custom field Co-Authored-By: Claude Sonnet 4.6 --- .../Mutations/Users/UserManagementMutation.php | 16 ++++++++++++++++ graphql/schemas/Ecosystem/user.graphql | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php index 4d92e2abee..ad9653847c 100644 --- a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php +++ b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php @@ -372,4 +372,20 @@ public function saveUserAppPreferences(mixed $rootValue, array $request): bool return true; } + + public function updateBannerUrl(mixed $rootValue, array $request): Users + { + $loggedUser = auth()->user(); + $app = app(Apps::class); + $userId = (int) $request['user_id']; + + if ($userId !== $loggedUser->getId() && ! $loggedUser->isAdmin()) { + throw new Exception('You are not allowed to update this user banner'); + } + + $user = UsersRepository::getUserOfAppById($userId, $app); + $user->set('banner_url', $request['url'], true); + + return $user; + } } diff --git a/graphql/schemas/Ecosystem/user.graphql b/graphql/schemas/Ecosystem/user.graphql index b8db90e975..e4fa0ea9f3 100644 --- a/graphql/schemas/Ecosystem/user.graphql +++ b/graphql/schemas/Ecosystem/user.graphql @@ -325,6 +325,11 @@ extend type Mutation { @field( resolver: "App\\GraphQL\\Ecosystem\\Mutations\\Users\\UserManagementMutation@saveUserAppPreferences" ) + updateBannerUrl(user_id: ID!, url: String!): User! + @guard + @field( + resolver: "App\\GraphQL\\Ecosystem\\Mutations\\Users\\UserManagementMutation@updateBannerUrl" + ) } type Query @guard { From bbe759cdb6ae152b6c869da64549c0802c1652c8 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Thu, 21 May 2026 10:12:15 -0400 Subject: [PATCH 02/13] fix(users): allow non-admin users to update their own banner URL Co-Authored-By: Claude Sonnet 4.6 --- .../Ecosystem/Mutations/Users/UserManagementMutation.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php index ad9653847c..c215c391a1 100644 --- a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php +++ b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php @@ -377,11 +377,9 @@ public function updateBannerUrl(mixed $rootValue, array $request): Users { $loggedUser = auth()->user(); $app = app(Apps::class); - $userId = (int) $request['user_id']; - - if ($userId !== $loggedUser->getId() && ! $loggedUser->isAdmin()) { - throw new Exception('You are not allowed to update this user banner'); - } + $userId = $loggedUser->isAdmin() && (int) $request['user_id'] > 0 + ? (int) $request['user_id'] + : $loggedUser->getId(); $user = UsersRepository::getUserOfAppById($userId, $app); $user->set('banner_url', $request['url'], true); From a28076acd1cba74bd1564b7389f71eaaa31fa976 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Mon, 25 May 2026 15:44:51 -0400 Subject: [PATCH 03/13] feat(agents): fix agent type upsert key, add latestDeployment relation, and relax deployment validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix CreateAgentTypeAction updateOrCreate to use apps_id+name as the match key - Fix AgentType model fillable: app_id → apps_id - Add latestDeployment relation on Agent model and expose it in GraphQL schema - Relax multi_agent_list DTO type from ?string to mixed - Remove blocking validation in AgentChannelIntegrationReadinessService Co-Authored-By: Claude Sonnet 4.6 --- graphql/schemas/Intelligence/agent.graphql | 1 + ...gentChannelIntegrationReadinessService.php | 9 ----- .../Agents/Actions/CreateAgentTypeAction.php | 40 ++++++++++--------- .../Agents/DataTransferObject/AgentType.php | 2 +- .../Intelligence/Agents/Models/Agent.php | 7 ++++ .../Intelligence/Agents/Models/AgentType.php | 2 +- 6 files changed, 32 insertions(+), 29 deletions(-) diff --git a/graphql/schemas/Intelligence/agent.graphql b/graphql/schemas/Intelligence/agent.graphql index cf8416f0bb..4f2f1a646f 100644 --- a/graphql/schemas/Intelligence/agent.graphql +++ b/graphql/schemas/Intelligence/agent.graphql @@ -22,6 +22,7 @@ type AgentAi { tools_config: String deployment_status: String! activeDeployment: AgentDeploymentType @hasOne + latestDeployment: AgentDeploymentType @hasOne agent_model_id: ID parent_id: ID is_active: Boolean! diff --git a/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php b/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php index 3741695d78..b363e2a760 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php @@ -14,15 +14,6 @@ class AgentChannelIntegrationReadinessService { public function assertReadyForDeployment(Agent $agent, string $provider): void { - if ($this->isReady($agent)) { - return; - } - - $this->notifyMissingIntegration($agent, $provider); - - throw new ValidationException( - 'Agent deployment requires a complete Slack integration or Telegram integration before launch.', - ); } public function isReady(Agent $agent): bool diff --git a/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php b/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php index fcee42d10d..a6420d5f03 100644 --- a/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php +++ b/src/Domains/Intelligence/Agents/Actions/CreateAgentTypeAction.php @@ -16,23 +16,27 @@ public function __construct( public function execute(): AgentTypeModel { - return AgentTypeModel::updateOrCreate([ - 'apps_id' => $this->agentType->app->id, - 'name' => $this->agentType->name, - 'description' => $this->agentType->description, - 'provider' => $this->agentType->provider, - 'handler' => $this->agentType->handler, - 'config' => $this->agentType->config, - 'role' => $this->agentType->role, - 'soul' => $this->agentType->soul, - 'instructions' => $this->agentType->instructions, - 'output_format' => $this->agentType->output_format, - 'is_active' => $this->agentType->is_active, - 'is_published' => $this->agentType->is_published, - 'is_multi_agent' => $this->agentType->is_multi_agent, - 'is_default' => $this->agentType->is_default, - 'weight' => $this->agentType->weight, - 'multi_agent_list' => $this->agentType->multi_agent_list ?? [], - ]); + return AgentTypeModel::updateOrCreate( + [ + 'apps_id' => $this->agentType->app->id, + 'name' => $this->agentType->name, + ], + [ + 'description' => $this->agentType->description, + 'provider' => $this->agentType->provider, + 'handler' => $this->agentType->handler, + 'config' => $this->agentType->config, + 'role' => $this->agentType->role ?? '', + 'soul' => $this->agentType->soul, + 'instructions' => $this->agentType->instructions, + 'output_format' => $this->agentType->output_format, + 'is_active' => $this->agentType->is_active, + 'is_published' => $this->agentType->is_published, + 'is_multi_agent' => $this->agentType->is_multi_agent, + 'is_default' => $this->agentType->is_default, + 'weight' => $this->agentType->weight, + 'multi_agent_list' => $this->agentType->multi_agent_list ?? [], + ] + ); } } diff --git a/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php b/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php index b42ab8458d..580534aa3c 100644 --- a/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php +++ b/src/Domains/Intelligence/Agents/DataTransferObject/AgentType.php @@ -23,7 +23,7 @@ public function __construct( public ?string $soul = null, public ?string $instructions = null, public ?string $output_format = null, - public ?string $multi_agent_list = null, + public mixed $multi_agent_list = null, public bool $is_default = false, public int $weight = 0, ) { diff --git a/src/Domains/Intelligence/Agents/Models/Agent.php b/src/Domains/Intelligence/Agents/Models/Agent.php index 520d9e37de..5941337584 100644 --- a/src/Domains/Intelligence/Agents/Models/Agent.php +++ b/src/Domains/Intelligence/Agents/Models/Agent.php @@ -297,6 +297,13 @@ public function activeDeployment(): HasOne ->latestOfMany(); } + public function latestDeployment(): HasOne + { + return $this->hasOne(AgentDeployment::class) + ->where('is_deleted', 0) + ->latestOfMany(); + } + public function searchableAs(): string { $app = $this->app ?? app(Apps::class); diff --git a/src/Domains/Intelligence/Agents/Models/AgentType.php b/src/Domains/Intelligence/Agents/Models/AgentType.php index 4a8e17943d..d1bec62220 100644 --- a/src/Domains/Intelligence/Agents/Models/AgentType.php +++ b/src/Domains/Intelligence/Agents/Models/AgentType.php @@ -50,7 +50,7 @@ class AgentType extends BaseModel protected $fillable = [ 'uuid', - 'app_id', + 'apps_id', 'name', 'description', 'provider', From 1c6f43dc417bbd26490bf32e79b2b130fca006cc Mon Sep 17 00:00:00 2001 From: Rafael White Date: Mon, 25 May 2026 15:47:58 -0400 Subject: [PATCH 04/13] =?UTF-8?q?revert:=20remove=20updateBannerUrl=20muta?= =?UTF-8?q?tion=20=E2=80=94=20no=20longer=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Mutations/Users/UserManagementMutation.php | 14 -------------- graphql/schemas/Ecosystem/user.graphql | 5 ----- 2 files changed, 19 deletions(-) diff --git a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php index c215c391a1..4d92e2abee 100644 --- a/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php +++ b/app/GraphQL/Ecosystem/Mutations/Users/UserManagementMutation.php @@ -372,18 +372,4 @@ public function saveUserAppPreferences(mixed $rootValue, array $request): bool return true; } - - public function updateBannerUrl(mixed $rootValue, array $request): Users - { - $loggedUser = auth()->user(); - $app = app(Apps::class); - $userId = $loggedUser->isAdmin() && (int) $request['user_id'] > 0 - ? (int) $request['user_id'] - : $loggedUser->getId(); - - $user = UsersRepository::getUserOfAppById($userId, $app); - $user->set('banner_url', $request['url'], true); - - return $user; - } } diff --git a/graphql/schemas/Ecosystem/user.graphql b/graphql/schemas/Ecosystem/user.graphql index e4fa0ea9f3..b8db90e975 100644 --- a/graphql/schemas/Ecosystem/user.graphql +++ b/graphql/schemas/Ecosystem/user.graphql @@ -325,11 +325,6 @@ extend type Mutation { @field( resolver: "App\\GraphQL\\Ecosystem\\Mutations\\Users\\UserManagementMutation@saveUserAppPreferences" ) - updateBannerUrl(user_id: ID!, url: String!): User! - @guard - @field( - resolver: "App\\GraphQL\\Ecosystem\\Mutations\\Users\\UserManagementMutation@updateBannerUrl" - ) } type Query @guard { From b11328537c8f2bc8bb8e13d9f9898db93c8d5e63 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Mon, 25 May 2026 15:49:02 -0400 Subject: [PATCH 05/13] revert: restore assertReadyForDeployment validation in AgentChannelIntegrationReadinessService Co-Authored-By: Claude Sonnet 4.6 --- .../Services/AgentChannelIntegrationReadinessService.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php b/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php index b363e2a760..3741695d78 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/AgentChannelIntegrationReadinessService.php @@ -14,6 +14,15 @@ class AgentChannelIntegrationReadinessService { public function assertReadyForDeployment(Agent $agent, string $provider): void { + if ($this->isReady($agent)) { + return; + } + + $this->notifyMissingIntegration($agent, $provider); + + throw new ValidationException( + 'Agent deployment requires a complete Slack integration or Telegram integration before launch.', + ); } public function isReady(Agent $agent): bool From a2ea1d3119395f5edbe497e71b5a5b11fc077ff8 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Mon, 25 May 2026 16:01:02 -0400 Subject: [PATCH 06/13] style(agents): remove trailing whitespace in Agent model Co-Authored-By: Claude Sonnet 4.6 --- src/Domains/Intelligence/Agents/Models/Agent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Domains/Intelligence/Agents/Models/Agent.php b/src/Domains/Intelligence/Agents/Models/Agent.php index e8ae079f02..b2d19145a4 100644 --- a/src/Domains/Intelligence/Agents/Models/Agent.php +++ b/src/Domains/Intelligence/Agents/Models/Agent.php @@ -305,7 +305,7 @@ public function latestDeployment(): HasOne ->where('is_deleted', 0) ->latestOfMany(); } - + public function isContainerRuntime(): bool { if ($this->activeDeployment instanceof AgentDeployment) { From 0cc2d665cb4d381472a41c5eeebe1827cd19e830 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Wed, 3 Jun 2026 11:42:41 -0400 Subject: [PATCH 07/13] feat(intelligence): add OpenTelemetry token usage collection pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces SSH polling for LLM token counts with a push-based OTel pipeline. Agent containers auto-instrument Anthropic/OpenAI calls via the OTel SDK; spans flow to an OTel Collector which aggregates gen_ai.usage.* attributes into metrics and forwards them to a new Laravel OTLP receiver stored in agent_usage_snapshots (source = '_otel'). - docker/otel/collector.yaml: OTLP gRPC receiver → spanmetrics connector → OTLP/HTTP exporter - docker/otel/otel-init.js: Node.js OTel SDK bootstrap (AnthropicInstrumentation auto-instruments LLM calls, uses KANVAS_DEPLOYMENT_ID resource attr) - docker-compose.yml: add otel-collector service (ports 4317/4318) - config/otel.php: enabled flag, collector_endpoint, internal_token - BaseDockerComposeBuilderService: inject OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_SERVICE_NAME when otel.enabled=true - OtlpAdapterController: thin REST bridge (X-Internal-Token auth) that forwards OTLP JSON to GraphQL mutation - ingestAgentTelemetry mutation + IngestOtlpMetricsMutation resolver - OtlpUsageIngestionService: parses OTLP metrics JSON, resolves deployment by ID or container_name, upserts AgentUsageSnapshot - CollectDeploymentUsageCommand: skip SSH poll when otel.enabled=true - Kernel.php: enable CollectAgentTelemetryCommand cron + add hourly usage snapshot schedule Enable via: OTEL_ENABLED=true, OTEL_INTERNAL_TOKEN= Co-Authored-By: Claude Sonnet 4.6 --- .../CollectDeploymentUsageCommand.php | 6 + app/Console/Kernel.php | 16 +- .../IngestOtlpMetricsMutation.php | 22 ++ .../Intelligence/OtlpAdapterController.php | 52 ++++ config/otel.php | 29 ++ docker-compose.yml | 15 ++ docker/otel/collector.yaml | 50 ++++ docker/otel/otel-init.js | 50 ++++ .../schemas/Intelligence/agentRuntime.graphql | 6 + routes/api.php | 4 + .../BaseDockerComposeBuilderService.php | 9 + .../Services/OtlpUsageIngestionService.php | 250 ++++++++++++++++++ 12 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php create mode 100644 app/Http/Controllers/Intelligence/OtlpAdapterController.php create mode 100644 config/otel.php create mode 100644 docker/otel/collector.yaml create mode 100644 docker/otel/otel-init.js create mode 100644 src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php diff --git a/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php b/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php index 3d807469a4..3fecd76172 100644 --- a/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php +++ b/app/Console/Commands/Connectors/OpenClaw/CollectDeploymentUsageCommand.php @@ -34,6 +34,12 @@ class CollectDeploymentUsageCommand extends Command public function handle(): void { + if (config('otel.enabled', false)) { + $this->info('OTel is active — token usage is collected via the collector pipeline. Skipping SSH poll.'); + + return; + } + /** @var Apps $app */ $app = Apps::getById((int) $this->argument('app_id')); $this->overwriteAppService($app); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index d4eb01b2d0..dc44199889 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,6 +6,7 @@ use App\Console\Commands\Connectors\Movipass\CheckExpiringOrdersCommand; use App\Console\Commands\Connectors\Notifications\MailCaddieLabCommand; use App\Console\Commands\Connectors\OpenClaw\CollectAgentTelemetryCommand; +use App\Console\Commands\Intelligence\CollectAllDeploymentsUsageCommand; use App\Console\Commands\Ecosystem\Users\DeleteUsersRequestedCommand; use App\Console\Commands\ImportPromptsFromDocsCommand; use App\Console\Commands\NervousSystem\Schedules\NervousSystemSchedule; @@ -50,10 +51,17 @@ protected function schedule(Schedule $schedule) // rollups, plan + capability sweeps, the daily-learning loop. NervousSystemSchedule::register($schedule); - /* $schedule->command(CollectAgentTelemetryCommand::class) - ->everyMinute() - ->withoutOverlapping(5) - ->runInBackground(); */ + // OpenClaw runtime telemetry — health, gateway, sessions, events (every minute). + $schedule->command(CollectAgentTelemetryCommand::class) + ->everyMinute() + ->withoutOverlapping(5) + ->runInBackground(); + + // Token usage snapshots — input/output/cache tokens per deployment (hourly). + $schedule->command(CollectAllDeploymentsUsageCommand::class) + ->hourly() + ->withoutOverlapping(10) + ->runInBackground(); //$schedule->command(SendBookingRemindersCommand::class)->everyFiveMinutes(); #$schedule->command(ScoutMessageReindexCommand::class, [env('MESSAGE_REINDEX_SCOUT_APP_ID', '13'), env('MESSAGE_REINDEX_SCOUT_MESSAGE_TYPES_ID', '572')])->everyTenMinutes(); #$schedule->command(MailunregisteredUsersCampaignCommand::class)->weeklyOn(2, '2:30'); //@todo move this to normal cron diff --git a/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php b/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php new file mode 100644 index 0000000000..959188c3a0 --- /dev/null +++ b/app/GraphQL/Intelligence/Mutations/AgentRuntime/IngestOtlpMetricsMutation.php @@ -0,0 +1,22 @@ + $payload */ + $payload = $args['payload'] ?? []; + + if (empty($payload)) { + return true; + } + + return app(OtlpUsageIngestionService::class)->ingest($payload); + } +} diff --git a/app/Http/Controllers/Intelligence/OtlpAdapterController.php b/app/Http/Controllers/Intelligence/OtlpAdapterController.php new file mode 100644 index 0000000000..0e345fab1d --- /dev/null +++ b/app/Http/Controllers/Intelligence/OtlpAdapterController.php @@ -0,0 +1,52 @@ +header('X-Internal-Token') !== $expectedToken) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $payload = $request->json()->all(); + + if (empty($payload)) { + // Return OTLP partial-success with no data — don't error on empty flushes + return response()->json(['partialSuccess' => new \stdClass()]); + } + + $graphql->executeQuery( + /* @lang GraphQL */ + 'mutation IngestOtlpMetrics($payload: Mixed!) { ingestAgentTelemetry(payload: $payload) }', + context: null, + variables: ['payload' => $payload], + ); + + // OTLP spec: return 200 with partialSuccess object (empty = full success) + return response()->json(['partialSuccess' => new \stdClass()]); + } +} diff --git a/config/otel.php b/config/otel.php new file mode 100644 index 0000000000..98f55dafb0 --- /dev/null +++ b/config/otel.php @@ -0,0 +1,29 @@ +_otel'). + | + */ + + 'enabled' => (bool) env('OTEL_ENABLED', false), + + // Endpoint the agent containers push spans to (must be reachable from the container network) + 'collector_endpoint' => env('OTEL_COLLECTOR_ENDPOINT', 'http://otel-collector:4317'), + + // Shared secret validated by the OTLP adapter endpoint. + // Set the same value in OTEL_INTERNAL_TOKEN in both the collector config and .env + 'internal_token' => env('OTEL_INTERNAL_TOKEN', ''), +]; diff --git a/docker-compose.yml b/docker-compose.yml index 17564ddb11..ebeebda549 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -236,6 +236,21 @@ services: networks: - sail + otel-collector: + image: otel/opentelemetry-collector-contrib:0.102.0 + container_name: otel-collector + command: ["--config=/etc/otel/config.yaml"] + environment: + OTEL_INTERNAL_TOKEN: "${OTEL_INTERNAL_TOKEN}" + volumes: + - ./docker/otel/collector.yaml:/etc/otel/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + networks: + - sail + restart: unless-stopped + puppeteer: image: browserless/chrome container_name: puppeteer${APP_CONTAINER_NAME} diff --git a/docker/otel/collector.yaml b/docker/otel/collector.yaml new file mode 100644 index 0000000000..1585b45c91 --- /dev/null +++ b/docker/otel/collector.yaml @@ -0,0 +1,50 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +connectors: + spanmetrics: + dimensions: + - name: gen_ai.system + - name: gen_ai.request.model + - name: agent.deployment_id + - name: agent.container_name + metrics_flush_interval: 30s + +processors: + batch: + timeout: 30s + filter/genai: + error_mode: ignore + spans: + include: + match_type: regexp + attributes: + - key: gen_ai.system + value: ".*" + +exporters: + otlphttp/kanvas: + endpoint: http://php:8000 + tls: + insecure: true + headers: + X-Internal-Token: "${env:OTEL_INTERNAL_TOKEN}" + # Uncomment for debugging — prints received spans/metrics to container logs + # debug: + # verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + processors: [filter/genai, batch] + exporters: [spanmetrics] + metrics: + receivers: [spanmetrics] + processors: [batch] + exporters: [otlphttp/kanvas] diff --git a/docker/otel/otel-init.js b/docker/otel/otel-init.js new file mode 100644 index 0000000000..01ee3b3613 --- /dev/null +++ b/docker/otel/otel-init.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * OpenTelemetry bootstrap — required via NODE_OPTIONS=--require /app/otel-init.js + * + * Auto-instruments Anthropic / OpenAI LLM calls and exports spans to the + * OTel Collector over gRPC. The spanmetrics connector in the collector + * aggregates gen_ai.usage.* attributes into metrics and forwards them to + * the Kanvas API for storage in agent_usage_snapshots. + * + * Resource attributes set here are attached to every span: + * agent.deployment_id — from KANVAS_DEPLOYMENT_ID (always set by the deploy action) + * agent.container_name — from HOSTNAME (Docker sets this to the container ID / name) + * service.name — from OTEL_SERVICE_NAME (set to "openclaw" or "hermes") + */ + +const { NodeSDK } = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc'); +const { Resource } = require('@opentelemetry/resources'); +const { AnthropicInstrumentation } = require('@traceloop/instrumentation-anthropic'); + +// Graceful no-op if the collector endpoint is not configured +const collectorEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://otel-collector:4317'; + +const sdk = new NodeSDK({ + resource: new Resource({ + 'service.name': process.env.OTEL_SERVICE_NAME ?? 'agent-runtime', + 'agent.deployment_id': process.env.KANVAS_DEPLOYMENT_ID ?? '', + 'agent.container_name': process.env.HOSTNAME ?? '', + }), + traceExporter: new OTLPTraceExporter({ + url: collectorEndpoint, + }), + instrumentations: [ + new AnthropicInstrumentation(), + ], +}); + +try { + sdk.start(); +} catch (err) { + // Never crash the agent runtime if OTel fails to start + console.warn('[otel-init] Failed to start OpenTelemetry SDK:', err?.message ?? err); +} + +process.on('SIGTERM', () => { + sdk.shutdown().catch((err) => { + console.warn('[otel-init] Error shutting down OpenTelemetry SDK:', err?.message ?? err); + }); +}); diff --git a/graphql/schemas/Intelligence/agentRuntime.graphql b/graphql/schemas/Intelligence/agentRuntime.graphql index 34066d24a8..42e2c8dda6 100644 --- a/graphql/schemas/Intelligence/agentRuntime.graphql +++ b/graphql/schemas/Intelligence/agentRuntime.graphql @@ -59,6 +59,12 @@ extend type Subscription { } extend type Mutation @guard { + "Ingest an OTLP metrics payload from the OTel Collector and persist token usage to agent_usage_snapshots." + ingestAgentTelemetry(payload: Mixed!): Boolean! + @field( + resolver: "App\\GraphQL\\Intelligence\\Mutations\\AgentRuntime\\IngestOtlpMetricsMutation" + ) + agentRuntimeCreateMachine(input: AgentMachineInput!): AgentMachineType! @field( resolver: "App\\GraphQL\\Intelligence\\Mutations\\AgentRuntime\\AgentMachineMutation@create" diff --git a/routes/api.php b/routes/api.php index 5d150f1ab9..2bf48f5605 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,10 @@ use Spatie\Health\Http\Controllers\SimpleHealthCheckController; Route::get('/', [\App\Http\Controllers\IndexController::class, 'index']); + +// OTel Collector → GraphQL bridge (Docker-internal only, validated by X-Internal-Token header) +Route::post('/telemetry/otlp', [\App\Http\Controllers\Intelligence\OtlpAdapterController::class, 'handle']) + ->middleware('throttle:600,1'); Route::post('/receiver/{uuid}', [\App\Http\Controllers\ReceiverController::class, 'store']); Route::get('/oauth/{uuid}', [\App\Http\Controllers\OAuthIntegrationController::class, 'auth']); Route::get('/oauth/{uuid}/callback', [\App\Http\Controllers\OAuthIntegrationController::class, 'callback']); diff --git a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php index 4415203262..0b5b5281b3 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php @@ -235,6 +235,15 @@ public function buildDockerCompose( $envVars['TELEGRAM_ALLOWED_USERS'] = (string) $telegramAllowedUsers; } + // OTel env vars — injected when otel.enabled is true so the container's + // otel-init.js can export spans to the collector on the same Docker network. + // KANVAS_DEPLOYMENT_ID is already set above and reused by otel-init.js as + // the agent.deployment_id resource attribute. + if (config('otel.enabled', false)) { + $envVars['OTEL_EXPORTER_OTLP_ENDPOINT'] = config('otel.collector_endpoint', 'http://otel-collector:4317'); + $envVars['OTEL_SERVICE_NAME'] = $envVars['OTEL_SERVICE_NAME'] ?? $this->getProviderConfig()->providerName; + } + $envLines = ''; foreach ($envVars as $key => $value) { $envLines .= " - {$key}={$value}\n"; diff --git a/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php new file mode 100644 index 0000000000..2a3ddc518b --- /dev/null +++ b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php @@ -0,0 +1,250 @@ +> $resourceMetrics */ + $resourceMetrics = $payload['resourceMetrics'] ?? []; + + if (empty($resourceMetrics)) { + return true; + } + + $succeeded = 0; + $failed = 0; + + foreach ($resourceMetrics as $rm) { + try { + $this->processResourceMetrics($rm); + $succeeded++; + } catch (Throwable $e) { + $failed++; + Log::warning('OtlpUsageIngestionService: failed to process resource metrics — ' . $e->getMessage(), [ + 'resource' => $rm['resource'] ?? null, + ]); + } + } + + if ($failed > 0) { + Log::warning("OtlpUsageIngestionService: {$failed} resource(s) failed, {$succeeded} succeeded."); + } + + return $failed === 0; + } + + /** + * @param array $rm One element of resourceMetrics[] + */ + private function processResourceMetrics(array $rm): void + { + $attrs = $this->extractAttributes($rm['resource']['attributes'] ?? []); + + $deploymentId = $attrs['agent.deployment_id'] ?? null; + $containerName = $attrs['agent.container_name'] ?? null; + + if (empty($deploymentId) && empty($containerName)) { + return; // Cannot resolve a deployment — skip silently + } + + $deployment = $this->resolveDeployment($deploymentId, $containerName); + + if ($deployment === null) { + Log::debug("OtlpUsageIngestionService: no deployment found for id={$deploymentId} container={$containerName}"); + + return; + } + + // Aggregate token counts across all scopeMetrics + $tokens = [ + 'input_tokens' => 0, + 'output_tokens' => 0, + 'total_tokens' => 0, + 'cache_read_tokens' => 0, + 'cache_write_tokens' => 0, + ]; + + $model = $attrs['gen_ai.request.model'] ?? null; + $system = $attrs['gen_ai.system'] ?? null; + + foreach ($rm['scopeMetrics'] ?? [] as $scope) { + foreach ($scope['metrics'] ?? [] as $metric) { + $name = $metric['name'] ?? ''; + $value = $this->extractFirstDataPointValue($metric); + + match ($name) { + 'gen_ai.usage.input_tokens' => $tokens['input_tokens'] += $value, + 'gen_ai.usage.output_tokens' => $tokens['output_tokens'] += $value, + 'gen_ai.usage.total_tokens' => $tokens['total_tokens'] += $value, + 'gen_ai.usage.cache_read_tokens' => $tokens['cache_read_tokens'] += $value, + 'gen_ai.usage.cache_write_tokens' => $tokens['cache_write_tokens'] += $value, + default => null, + }; + + // Extract model/system from metric attributes if not in resource + if ($model === null || $system === null) { + $metricAttrs = $this->extractAttributes($metric['gauge']['dataPoints'][0]['attributes'] ?? []); + $model ??= $metricAttrs['gen_ai.request.model'] ?? null; + $system ??= $metricAttrs['gen_ai.system'] ?? null; + } + } + } + + // If total_tokens wasn't emitted separately, compute it + if ($tokens['total_tokens'] === 0 && ($tokens['input_tokens'] + $tokens['output_tokens']) > 0) { + $tokens['total_tokens'] = $tokens['input_tokens'] + $tokens['output_tokens']; + } + + // Nothing useful to store + if ($tokens['total_tokens'] === 0) { + return; + } + + $provider = $deployment->provider + ?? ($system !== null ? $system : null) + ?? ($model !== null ? BaseCollectDeploymentUsageAction::inferLlmProvider($model) : null); + + try { + $app = Apps::getById($deployment->apps_id); + $company = Companies::getById($deployment->companies_id); + } catch (Throwable) { + Log::warning("OtlpUsageIngestionService: could not load app/company for deployment #{$deployment->getId()}"); + + return; + } + + $source = ($provider !== null ? strtolower($provider) : 'agent') . '_otel'; + + AgentUsageSnapshot::updateOrCreate( + [ + 'apps_id' => $app->getId(), + 'companies_id' => $company->getId(), + 'agent_deployment_id' => $deployment->getId(), + 'snapshot_date' => now()->toDateString(), + 'source' => $source, + ], + [ + 'input_tokens' => $tokens['input_tokens'], + 'output_tokens' => $tokens['output_tokens'], + 'total_tokens' => $tokens['total_tokens'], + 'cache_read_tokens' => $tokens['cache_read_tokens'], + 'cache_write_tokens' => $tokens['cache_write_tokens'], + 'provider' => $provider, + 'model' => $model, + 'total_sessions' => 0, // Sessions tracked separately via telemetry service + 'raw_output' => null, + 'parsed_data' => $tokens, + ] + ); + } + + /** + * Resolve a deployment by ID first, falling back to container_name. + */ + private function resolveDeployment(?string $deploymentId, ?string $containerName): ?AgentDeployment + { + if (! empty($deploymentId)) { + /** @var AgentDeployment|null $deployment */ + $deployment = AgentDeployment::find((int) $deploymentId); + + if ($deployment !== null) { + return $deployment; + } + } + + if (! empty($containerName)) { + return AgentDeployment::where('container_name', $containerName) + ->where('is_deleted', 0) + ->first(); + } + + return null; + } + + /** + * Convert OTLP attribute list [{"key":"k","value":{"stringValue":"v"}}] + * into a flat key→value map. + * + * @param array> $attributes + * @return array + */ + private function extractAttributes(array $attributes): array + { + $result = []; + + foreach ($attributes as $attr) { + $key = $attr['key'] ?? ''; + + if ($key === '') { + continue; + } + + $valueWrapper = $attr['value'] ?? []; + $value = $valueWrapper['stringValue'] + ?? (string) ($valueWrapper['intValue'] ?? $valueWrapper['doubleValue'] ?? ''); + + $result[$key] = $value; + } + + return $result; + } + + /** + * Extract the first data-point value from a gauge or sum metric. + * Handles both intValue (string) and doubleValue. + */ + private function extractFirstDataPointValue(array $metric): int + { + $dataPoints = $metric['gauge']['dataPoints'] + ?? $metric['sum']['dataPoints'] + ?? []; + + if (empty($dataPoints)) { + return 0; + } + + $dp = $dataPoints[0]; + + return (int) ($dp['asInt'] ?? $dp['asDouble'] ?? 0); + } +} From a64a54b4a6f9916600c476e7e52c8a8a0b8bfe87 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Wed, 3 Jun 2026 11:44:28 -0400 Subject: [PATCH 08/13] style(console): reorder imports in Kernel alphabetically --- app/Console/Kernel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index dc44199889..5d1430796d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,9 +6,9 @@ use App\Console\Commands\Connectors\Movipass\CheckExpiringOrdersCommand; use App\Console\Commands\Connectors\Notifications\MailCaddieLabCommand; use App\Console\Commands\Connectors\OpenClaw\CollectAgentTelemetryCommand; -use App\Console\Commands\Intelligence\CollectAllDeploymentsUsageCommand; use App\Console\Commands\Ecosystem\Users\DeleteUsersRequestedCommand; use App\Console\Commands\ImportPromptsFromDocsCommand; +use App\Console\Commands\Intelligence\CollectAllDeploymentsUsageCommand; use App\Console\Commands\NervousSystem\Schedules\NervousSystemSchedule; use App\Console\Commands\Social\ScoutMessageReindexCommand; use App\Console\Commands\Social\SocialUserCounterResetCommand; From 10305e4dbd4746d3e19e8e0b4145447461c1b851 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Wed, 3 Jun 2026 12:49:41 -0400 Subject: [PATCH 09/13] fix(intelligence): add missing CollectAllDeploymentsUsageCommand and make inferLlmProvider public - CollectAllDeploymentsUsageCommand was referenced in Kernel.php but not committed - inferLlmProvider() changed from protected to public static so OtlpUsageIngestionService can call it Co-Authored-By: Claude Sonnet 4.6 --- .../CollectAllDeploymentsUsageCommand.php | 106 ++++++++++++++++++ .../BaseCollectDeploymentUsageAction.php | 2 +- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php diff --git a/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php b/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php new file mode 100644 index 0000000000..45bb4f9495 --- /dev/null +++ b/app/Console/Commands/Intelligence/CollectAllDeploymentsUsageCommand.php @@ -0,0 +1,106 @@ +info('OTel is active — token usage is collected via the collector pipeline. Skipping SSH poll.'); + + return self::SUCCESS; + } + + $specificId = $this->option('deployment'); + $dryRun = (bool) $this->option('dry-run'); + + $query = AgentDeployment::where('status', 'running') + ->where('is_deleted', 0) + ->with(['machine', 'agent']); + + if ($specificId !== null) { + $query->where('id', (int) $specificId); + } + + $deployments = $query->get(); + + if ($deployments->isEmpty()) { + $this->info('No running deployments found.'); + + return self::SUCCESS; + } + + $this->info("Found {$deployments->count()} running deployment(s). Collecting usage..."); + + $succeeded = 0; + $failed = 0; + + foreach ($deployments as $deployment) { + /** @var AgentDeployment $deployment */ + $label = "deployment #{$deployment->getId()} (container: {$deployment->container_name}, app: {$deployment->apps_id})"; + + if ($dryRun) { + $this->line(" [dry-run] Would collect: {$label}"); + $succeeded++; + + continue; + } + + try { + $app = Apps::getById($deployment->apps_id); + $company = Companies::getById($deployment->companies_id); + $provider = AgentRuntimeProviderFactory::forDeployment($deployment); + + $snapshot = $provider->collectUsage($deployment, $app, $company); + + $this->info( + " ✓ {$label}" + . " → snapshot #{$snapshot->getId()}" + . " | total_tokens={$snapshot->total_tokens}" + . " | in={$snapshot->input_tokens} out={$snapshot->output_tokens}" + ); + + $succeeded++; + } catch (Throwable $e) { + $this->error(" ✗ {$label}: {$e->getMessage()}"); + $failed++; + } + } + + $this->newLine(); + $this->info("Done — succeeded: {$succeeded}, failed: {$failed}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php b/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php index 0008c55e30..5a1687a77f 100644 --- a/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php +++ b/src/Domains/Intelligence/AgentRuntime/Actions/BaseCollectDeploymentUsageAction.php @@ -74,7 +74,7 @@ public function execute(): AgentUsageSnapshot ); } - protected static function inferLlmProvider(string $model): ?string + public static function inferLlmProvider(string $model): ?string { if ($model === '') { return null; From 080cad7073a3413ae93678240008cea69876b646 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Tue, 9 Jun 2026 11:51:29 -0400 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20complete=20OTel=20pipeline=20?= =?UTF-8?q?=E2=80=94=20forward=20spans=20directly=20to=20Laravel=20for=20t?= =?UTF-8?q?oken=20usage=20ingestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile templates: add {{OTEL_LAYER}} placeholder; buildDockerfile() expands it to a RUN layer that installs OTel packages at /opt/otel when otel.enabled=true - BaseLaunchAgentOnMachineAction: write otel-init.js to Docker build context when enabled - BaseDockerComposeBuilderService: inject NODE_OPTIONS=--require /opt/otel/init.js alongside OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_SERVICE_NAME when enabled - otel-init.js: absolute requires from /opt/otel/node_modules, bail-out guard on OTEL_EXPORTER_OTLP_ENDPOINT, use KANVAS_DEPLOYMENT_ID resource attribute - collector.yaml: drop spanmetrics (only produces calls/duration, not token counts); forward raw gen_ai.* spans directly to Laravel via traces_endpoint; batch timeout 10s - OtlpUsageIngestionService: primary path parses OTLP traces (resourceSpans) extracting gen_ai.usage.* attributes per span, aggregated per deployment; legacy metrics path kept for backward compat; upsertUsageSnapshot() shared by both paths; raw_output='' fix - OtlpAdapterController: call OtlpUsageIngestionService directly (no GraphQL tunnel); GraphQL @guard blocked system calls without auth context - docker-compose.yml: otel-collector depends_on php so first export doesn't hit cold target Co-Authored-By: Claude Sonnet 4.6 --- .../Intelligence/OtlpAdapterController.php | 16 +- docker-compose.yml | 2 + docker/otel/collector.yaml | 22 +- docker/otel/otel-init.js | 61 ++--- .../Connectors/Hermes/Templates/Dockerfile | 2 + .../Connectors/OpenClaw/Templates/Dockerfile | 1 + .../BaseLaunchAgentOnMachineAction.php | 6 + .../BaseDockerComposeBuilderService.php | 52 +++- .../Services/OtlpUsageIngestionService.php | 227 ++++++++++++------ 9 files changed, 257 insertions(+), 132 deletions(-) diff --git a/app/Http/Controllers/Intelligence/OtlpAdapterController.php b/app/Http/Controllers/Intelligence/OtlpAdapterController.php index 0e345fab1d..4647e9fd29 100644 --- a/app/Http/Controllers/Intelligence/OtlpAdapterController.php +++ b/app/Http/Controllers/Intelligence/OtlpAdapterController.php @@ -7,16 +7,15 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; -use Nuwave\Lighthouse\GraphQL; +use Kanvas\Intelligence\AgentRuntime\Services\OtlpUsageIngestionService; /** * Thin adapter that bridges the OTel Collector (OTLP/HTTP JSON) to the - * Lighthouse GraphQL mutation `ingestAgentTelemetry`. + * OtlpUsageIngestionService. * * The collector speaks OTLP — it cannot send a GraphQL query body directly. * This controller accepts the raw OTLP metrics JSON, validates the shared - * internal token, and programmatically executes the mutation so all business - * logic lives in the GraphQL resolver layer. + * internal token, and delegates directly to the ingestion service. * * This endpoint is intended to be reachable only from within the Docker * bridge network (otel-collector → php container). It is not guarded by @@ -24,7 +23,7 @@ */ class OtlpAdapterController extends Controller { - public function handle(Request $request, GraphQL $graphql): JsonResponse + public function handle(Request $request, OtlpUsageIngestionService $ingestionService): JsonResponse { $expectedToken = config('otel.internal_token', ''); @@ -39,12 +38,7 @@ public function handle(Request $request, GraphQL $graphql): JsonResponse return response()->json(['partialSuccess' => new \stdClass()]); } - $graphql->executeQuery( - /* @lang GraphQL */ - 'mutation IngestOtlpMetrics($payload: Mixed!) { ingestAgentTelemetry(payload: $payload) }', - context: null, - variables: ['payload' => $payload], - ); + $ingestionService->ingest($payload); // OTLP spec: return 200 with partialSuccess object (empty = full success) return response()->json(['partialSuccess' => new \stdClass()]); diff --git a/docker-compose.yml b/docker-compose.yml index 08d559dc29..abe6c58f71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -257,6 +257,8 @@ services: - "4318:4318" networks: - sail + depends_on: + - php restart: unless-stopped puppeteer: diff --git a/docker/otel/collector.yaml b/docker/otel/collector.yaml index 1585b45c91..23d4bf75c1 100644 --- a/docker/otel/collector.yaml +++ b/docker/otel/collector.yaml @@ -6,18 +6,9 @@ receivers: http: endpoint: 0.0.0.0:4318 -connectors: - spanmetrics: - dimensions: - - name: gen_ai.system - - name: gen_ai.request.model - - name: agent.deployment_id - - name: agent.container_name - metrics_flush_interval: 30s - processors: batch: - timeout: 30s + timeout: 10s filter/genai: error_mode: ignore spans: @@ -29,12 +20,15 @@ processors: exporters: otlphttp/kanvas: - endpoint: http://php:8000 + # traces_endpoint is used as-is (no suffix appended), pointing directly at + # our adapter route. The adapter parses OTLP traces JSON and extracts + # gen_ai.usage.* attributes to upsert into agent_usage_snapshots. + traces_endpoint: http://php:8000/v1/telemetry/otlp tls: insecure: true headers: X-Internal-Token: "${env:OTEL_INTERNAL_TOKEN}" - # Uncomment for debugging — prints received spans/metrics to container logs + # Uncomment for debugging — prints received spans to container logs # debug: # verbosity: detailed @@ -43,8 +37,4 @@ service: traces: receivers: [otlp] processors: [filter/genai, batch] - exporters: [spanmetrics] - metrics: - receivers: [spanmetrics] - processors: [batch] exporters: [otlphttp/kanvas] diff --git a/docker/otel/otel-init.js b/docker/otel/otel-init.js index 01ee3b3613..c8eeb4136a 100644 --- a/docker/otel/otel-init.js +++ b/docker/otel/otel-init.js @@ -1,7 +1,7 @@ 'use strict'; /** - * OpenTelemetry bootstrap — required via NODE_OPTIONS=--require /app/otel-init.js + * OpenTelemetry bootstrap — required via NODE_OPTIONS=--require /opt/otel/init.js * * Auto-instruments Anthropic / OpenAI LLM calls and exports spans to the * OTel Collector over gRPC. The spanmetrics connector in the collector @@ -12,39 +12,44 @@ * agent.deployment_id — from KANVAS_DEPLOYMENT_ID (always set by the deploy action) * agent.container_name — from HOSTNAME (Docker sets this to the container ID / name) * service.name — from OTEL_SERVICE_NAME (set to "openclaw" or "hermes") + * + * Packages are installed at /opt/otel/node_modules/ during the image build + * (see the {{OTEL_LAYER}} section in the Dockerfile template). + * Absolute requires keep this script isolated from the app's own node_modules. */ -const { NodeSDK } = require('@opentelemetry/sdk-node'); -const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc'); -const { Resource } = require('@opentelemetry/resources'); -const { AnthropicInstrumentation } = require('@traceloop/instrumentation-anthropic'); +// Bail out silently if the collector endpoint is not configured — this prevents +// the script from crashing agents that were deployed before OTel was enabled. +if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { + return; +} -// Graceful no-op if the collector endpoint is not configured -const collectorEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://otel-collector:4317'; +try { + const { NodeSDK } = require('/opt/otel/node_modules/@opentelemetry/sdk-node'); + const { OTLPTraceExporter } = require('/opt/otel/node_modules/@opentelemetry/exporter-trace-otlp-grpc'); + const { Resource } = require('/opt/otel/node_modules/@opentelemetry/resources'); + const { AnthropicInstrumentation } = require('/opt/otel/node_modules/@traceloop/instrumentation-anthropic'); -const sdk = new NodeSDK({ - resource: new Resource({ - 'service.name': process.env.OTEL_SERVICE_NAME ?? 'agent-runtime', - 'agent.deployment_id': process.env.KANVAS_DEPLOYMENT_ID ?? '', - 'agent.container_name': process.env.HOSTNAME ?? '', - }), - traceExporter: new OTLPTraceExporter({ - url: collectorEndpoint, - }), - instrumentations: [ - new AnthropicInstrumentation(), - ], -}); + const sdk = new NodeSDK({ + resource: new Resource({ + 'service.name': process.env.OTEL_SERVICE_NAME || 'agent-runtime', + 'agent.deployment_id': process.env.KANVAS_DEPLOYMENT_ID || '', + 'agent.container_name': process.env.HOSTNAME || '', + }), + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + }), + instrumentations: [ + new AnthropicInstrumentation(), + ], + }); -try { sdk.start(); + + process.on('SIGTERM', () => { + sdk.shutdown().catch(() => {}); + }); } catch (err) { // Never crash the agent runtime if OTel fails to start - console.warn('[otel-init] Failed to start OpenTelemetry SDK:', err?.message ?? err); + process.stderr.write('[otel-init] Failed to start: ' + (err && err.message ? err.message : String(err)) + '\n'); } - -process.on('SIGTERM', () => { - sdk.shutdown().catch((err) => { - console.warn('[otel-init] Error shutting down OpenTelemetry SDK:', err?.message ?? err); - }); -}); diff --git a/src/Domains/Connectors/Hermes/Templates/Dockerfile b/src/Domains/Connectors/Hermes/Templates/Dockerfile index 587d3d9450..6bd6b253bd 100644 --- a/src/Domains/Connectors/Hermes/Templates/Dockerfile +++ b/src/Domains/Connectors/Hermes/Templates/Dockerfile @@ -1 +1,3 @@ FROM {{BASE_IMAGE}} + +{{OTEL_LAYER}} \ No newline at end of file diff --git a/src/Domains/Connectors/OpenClaw/Templates/Dockerfile b/src/Domains/Connectors/OpenClaw/Templates/Dockerfile index 86fa9ca798..16819c691d 100644 --- a/src/Domains/Connectors/OpenClaw/Templates/Dockerfile +++ b/src/Domains/Connectors/OpenClaw/Templates/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update \ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh +{{OTEL_LAYER}} USER node ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php b/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php index 543ae3cff2..1e81bc0591 100644 --- a/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php +++ b/src/Domains/Intelligence/AgentRuntime/Actions/BaseLaunchAgentOnMachineAction.php @@ -127,6 +127,12 @@ private function ensureSharedImage(SshClient $client): void $client->writeFileAsUser($imageDir . '/entrypoint.sh', $builder->buildEntrypoint(), 'root'); $client->exec('sudo chmod +x ' . escapeshellarg($imageDir . '/entrypoint.sh')); + // When OTel is enabled, the Dockerfile includes a COPY otel-init.js layer. + // The file must be present in the build context before `docker build` runs. + if (config('otel.enabled', false)) { + $client->writeFileAsUser($imageDir . '/otel-init.js', $builder->buildOtelInitScript(), 'root'); + } + $result = $client->exec( 'cd ' . escapeshellarg($imageDir) . ' && sudo docker build -t ' . escapeshellarg($imageName) . ' . 2>&1; echo "EXIT_CODE:$?"', 900, diff --git a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php index 0b5b5281b3..38c2744dd8 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/BaseDockerComposeBuilderService.php @@ -104,9 +104,52 @@ public function buildDockerfile(AppInterface $app): string $raw = (string) file_get_contents(static::getTemplatesDir() . '/Dockerfile'); - // Substitute `{{BASE_IMAGE}}` if the template uses it (pinned providers); raw passthrough - // otherwise (providers that haven't adopted the placeholder yet). - return rtrim(str_replace('{{BASE_IMAGE}}', $this->getBaseImage($app), $raw)); + // Substitute `{{BASE_IMAGE}}` and `{{OTEL_LAYER}}`. The OTel layer installs the + // Node.js OTel packages at /opt/otel so otel-init.js can use absolute requires — + // keeping the instrumentation fully isolated from the app's own node_modules. + // The layer is only emitted when otel.enabled=true; otherwise the placeholder is + // replaced with an empty string so the Dockerfile stays valid. + $otelLayer = config('otel.enabled', false) ? $this->buildOtelDockerfileLayer() : ''; + + return rtrim(str_replace( + ['{{BASE_IMAGE}}', '{{OTEL_LAYER}}'], + [$this->getBaseImage($app), $otelLayer], + $raw, + )); + } + + /** + * Dockerfile RUN layer that installs the OTel Node.js packages at /opt/otel and + * copies otel-init.js there. The file must already exist in the Docker build context + * (written by BaseLaunchAgentOnMachineAction::ensureSharedImage when otel.enabled). + */ + protected function buildOtelDockerfileLayer(): string + { + return implode("\n", [ + '# OTel token-usage instrumentation (added when otel.enabled=true)', + 'USER root', + 'RUN mkdir -p /opt/otel', + 'COPY otel-init.js /opt/otel/init.js', + 'RUN cd /opt/otel \\', + ' && npm init -y \\', + ' && npm install --save \\', + ' @opentelemetry/sdk-node \\', + ' @opentelemetry/exporter-trace-otlp-grpc \\', + ' @opentelemetry/resources \\', + ' @traceloop/instrumentation-anthropic', + ]); + } + + /** + * Content of otel-init.js that is written to the Docker build context and later + * installed at /opt/otel/init.js inside the image. Read from the canonical source + * in docker/otel/ so the template and init script are always in sync. + */ + public function buildOtelInitScript(): string + { + $path = base_path('docker/otel/otel-init.js'); + + return file_exists($path) ? (string) file_get_contents($path) : ''; } /** @@ -239,9 +282,12 @@ public function buildDockerCompose( // otel-init.js can export spans to the collector on the same Docker network. // KANVAS_DEPLOYMENT_ID is already set above and reused by otel-init.js as // the agent.deployment_id resource attribute. + // NODE_OPTIONS bootstraps the SDK before any app code runs (--require). if (config('otel.enabled', false)) { $envVars['OTEL_EXPORTER_OTLP_ENDPOINT'] = config('otel.collector_endpoint', 'http://otel-collector:4317'); $envVars['OTEL_SERVICE_NAME'] = $envVars['OTEL_SERVICE_NAME'] ?? $this->getProviderConfig()->providerName; + $envVars['NODE_OPTIONS'] = ($envVars['NODE_OPTIONS'] ?? '') . ' --require /opt/otel/init.js'; + $envVars['NODE_OPTIONS'] = ltrim($envVars['NODE_OPTIONS']); } $envLines = ''; diff --git a/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php index 2a3ddc518b..c5a94420cb 100644 --- a/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php +++ b/src/Domains/Intelligence/AgentRuntime/Services/OtlpUsageIngestionService.php @@ -13,29 +13,32 @@ use Throwable; /** - * Parse an OTLP/HTTP metrics payload (JSON encoding) and persist token usage + * Parse an OTLP/HTTP traces payload (JSON encoding) and persist token usage * into agent_usage_snapshots. * - * The OTel Collector's spanmetrics connector produces one metric per span - * operation with the resource attributes attached. We look for metrics whose - * names carry the gen_ai.usage.* prefix (e.g. `gen_ai.usage.input_tokens`) - * and aggregate their data-point values per deployment. + * The OTel Collector forwards raw spans (no spanmetrics aggregation) and we + * extract gen_ai.usage.* attributes directly from each span. Token counts are + * summed per deployment per day and upserted. * - * Expected payload shape (OTLP metrics JSON): + * Expected payload shape (OTLP traces JSON): * { - * "resourceMetrics": [{ + * "resourceSpans": [{ * "resource": { * "attributes": [ * {"key": "agent.deployment_id", "value": {"stringValue": "42"}}, - * {"key": "agent.container_name", "value": {"stringValue": "openclaw-42"}}, - * {"key": "gen_ai.request.model", "value": {"stringValue": "claude-sonnet-4-5"}} + * {"key": "agent.container_name", "value": {"stringValue": "openclaw-42"}} * ] * }, - * "scopeMetrics": [{ - * "metrics": [{ - * "name": "gen_ai.usage.input_tokens", - * "gauge": { "dataPoints": [{"asInt": "1240"}] } - * }, ...] + * "scopeSpans": [{ + * "spans": [{ + * "attributes": [ + * {"key": "gen_ai.system", "value": {"stringValue": "anthropic"}}, + * {"key": "gen_ai.request.model", "value": {"stringValue": "claude-sonnet-4-5"}}, + * {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "820"}}, + * {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "210"}}, + * {"key": "gen_ai.usage.total_tokens", "value": {"intValue": "1030"}} + * ] + * }] * }] * }] * } @@ -44,19 +47,140 @@ class OtlpUsageIngestionService { public function ingest(array $payload): bool { - /** @var array> $resourceMetrics */ + // Support both OTLP traces format (resourceSpans) and metrics format (resourceMetrics). + // The collector now forwards raw spans so we primarily use resourceSpans. + $resourceSpans = $payload['resourceSpans'] ?? []; + + if (! empty($resourceSpans)) { + return $this->ingestTraces($resourceSpans); + } + + // Legacy metrics path — kept for backward compat with direct API calls $resourceMetrics = $payload['resourceMetrics'] ?? []; - if (empty($resourceMetrics)) { - return true; + if (! empty($resourceMetrics)) { + return $this->ingestMetrics($resourceMetrics); + } + + return true; + } + + /** + * Parse OTLP traces format: resourceSpans[].scopeSpans[].spans[].attributes + * + * @param array> $resourceSpans + */ + private function ingestTraces(array $resourceSpans): bool + { + $succeeded = 0; + $failed = 0; + + // Aggregate tokens per deployment across all spans in this batch + /** @var array}> $byDeployment */ + $byDeployment = []; + + foreach ($resourceSpans as $rs) { + $resourceAttrs = $this->extractAttributes($rs['resource']['attributes'] ?? []); + $deploymentKey = $resourceAttrs['agent.deployment_id'] ?? $resourceAttrs['agent.container_name'] ?? null; + + if ($deploymentKey === null) { + continue; + } + + if (! isset($byDeployment[$deploymentKey])) { + $byDeployment[$deploymentKey] = [ + 'deployment_id' => $resourceAttrs['agent.deployment_id'] ?? null, + 'container_name' => $resourceAttrs['agent.container_name'] ?? null, + 'model' => null, + 'system' => null, + 'tokens' => ['input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cache_read_tokens' => 0, 'cache_write_tokens' => 0], + ]; + } + + foreach ($rs['scopeSpans'] ?? [] as $scope) { + foreach ($scope['spans'] ?? [] as $span) { + $spanAttrs = $this->extractAttributes($span['attributes'] ?? []); + + $byDeployment[$deploymentKey]['model'] ??= $spanAttrs['gen_ai.request.model'] ?? null; + $byDeployment[$deploymentKey]['system'] ??= $spanAttrs['gen_ai.system'] ?? null; + + $t = &$byDeployment[$deploymentKey]['tokens']; + $t['input_tokens'] += (int) ($spanAttrs['gen_ai.usage.input_tokens'] ?? 0); + $t['output_tokens'] += (int) ($spanAttrs['gen_ai.usage.output_tokens'] ?? 0); + $t['total_tokens'] += (int) ($spanAttrs['gen_ai.usage.total_tokens'] ?? 0); + $t['cache_read_tokens'] += (int) ($spanAttrs['gen_ai.usage.cache_read_tokens'] ?? $spanAttrs['gen_ai.usage.cache.read_input_tokens'] ?? 0); + $t['cache_write_tokens'] += (int) ($spanAttrs['gen_ai.usage.cache_write_tokens'] ?? $spanAttrs['gen_ai.usage.cache.creation_input_tokens'] ?? 0); + unset($t); + } + } + } + + foreach ($byDeployment as $bucket) { + try { + $this->upsertUsageSnapshot( + $bucket['deployment_id'], + $bucket['container_name'], + $bucket['model'], + $bucket['system'], + $bucket['tokens'], + ); + $succeeded++; + } catch (Throwable $e) { + $failed++; + Log::warning('OtlpUsageIngestionService: failed to upsert snapshot — ' . $e->getMessage(), [ + 'deployment_id' => $bucket['deployment_id'], + 'container_name' => $bucket['container_name'], + ]); + } + } + + if ($failed > 0) { + Log::warning("OtlpUsageIngestionService: {$failed} deployment(s) failed, {$succeeded} succeeded."); } + return $failed === 0; + } + + /** + * Legacy OTLP metrics format: resourceMetrics[].scopeMetrics[].metrics[] + * + * @param array> $resourceMetrics + */ + private function ingestMetrics(array $resourceMetrics): bool + { $succeeded = 0; $failed = 0; foreach ($resourceMetrics as $rm) { try { - $this->processResourceMetrics($rm); + $attrs = $this->extractAttributes($rm['resource']['attributes'] ?? []); + $tokens = ['input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cache_read_tokens' => 0, 'cache_write_tokens' => 0]; + $model = $attrs['gen_ai.request.model'] ?? null; + $system = $attrs['gen_ai.system'] ?? null; + + foreach ($rm['scopeMetrics'] ?? [] as $scope) { + foreach ($scope['metrics'] ?? [] as $metric) { + $name = $metric['name'] ?? ''; + $value = $this->extractFirstDataPointValue($metric); + + match ($name) { + 'gen_ai.usage.input_tokens' => $tokens['input_tokens'] += $value, + 'gen_ai.usage.output_tokens' => $tokens['output_tokens'] += $value, + 'gen_ai.usage.total_tokens' => $tokens['total_tokens'] += $value, + 'gen_ai.usage.cache_read_tokens' => $tokens['cache_read_tokens'] += $value, + 'gen_ai.usage.cache_write_tokens' => $tokens['cache_write_tokens'] += $value, + default => null, + }; + + if ($model === null || $system === null) { + $dp0Attrs = $this->extractAttributes($metric['gauge']['dataPoints'][0]['attributes'] ?? []); + $model ??= $dp0Attrs['gen_ai.request.model'] ?? null; + $system ??= $dp0Attrs['gen_ai.system'] ?? null; + } + } + } + + $this->upsertUsageSnapshot($attrs['agent.deployment_id'] ?? null, $attrs['agent.container_name'] ?? null, $model, $system, $tokens); $succeeded++; } catch (Throwable $e) { $failed++; @@ -74,17 +198,14 @@ public function ingest(array $payload): bool } /** - * @param array $rm One element of resourceMetrics[] + * Resolve deployment and upsert AgentUsageSnapshot. + * + * @param array $tokens */ - private function processResourceMetrics(array $rm): void + private function upsertUsageSnapshot(?string $deploymentId, ?string $containerName, ?string $model, ?string $system, array $tokens): void { - $attrs = $this->extractAttributes($rm['resource']['attributes'] ?? []); - - $deploymentId = $attrs['agent.deployment_id'] ?? null; - $containerName = $attrs['agent.container_name'] ?? null; - if (empty($deploymentId) && empty($containerName)) { - return; // Cannot resolve a deployment — skip silently + return; } $deployment = $this->resolveDeployment($deploymentId, $containerName); @@ -95,63 +216,21 @@ private function processResourceMetrics(array $rm): void return; } - // Aggregate token counts across all scopeMetrics - $tokens = [ - 'input_tokens' => 0, - 'output_tokens' => 0, - 'total_tokens' => 0, - 'cache_read_tokens' => 0, - 'cache_write_tokens' => 0, - ]; - - $model = $attrs['gen_ai.request.model'] ?? null; - $system = $attrs['gen_ai.system'] ?? null; - - foreach ($rm['scopeMetrics'] ?? [] as $scope) { - foreach ($scope['metrics'] ?? [] as $metric) { - $name = $metric['name'] ?? ''; - $value = $this->extractFirstDataPointValue($metric); - - match ($name) { - 'gen_ai.usage.input_tokens' => $tokens['input_tokens'] += $value, - 'gen_ai.usage.output_tokens' => $tokens['output_tokens'] += $value, - 'gen_ai.usage.total_tokens' => $tokens['total_tokens'] += $value, - 'gen_ai.usage.cache_read_tokens' => $tokens['cache_read_tokens'] += $value, - 'gen_ai.usage.cache_write_tokens' => $tokens['cache_write_tokens'] += $value, - default => null, - }; - - // Extract model/system from metric attributes if not in resource - if ($model === null || $system === null) { - $metricAttrs = $this->extractAttributes($metric['gauge']['dataPoints'][0]['attributes'] ?? []); - $model ??= $metricAttrs['gen_ai.request.model'] ?? null; - $system ??= $metricAttrs['gen_ai.system'] ?? null; - } - } - } - - // If total_tokens wasn't emitted separately, compute it + // Compute total if not emitted separately if ($tokens['total_tokens'] === 0 && ($tokens['input_tokens'] + $tokens['output_tokens']) > 0) { $tokens['total_tokens'] = $tokens['input_tokens'] + $tokens['output_tokens']; } - // Nothing useful to store if ($tokens['total_tokens'] === 0) { return; } $provider = $deployment->provider - ?? ($system !== null ? $system : null) + ?? ($system ?? null) ?? ($model !== null ? BaseCollectDeploymentUsageAction::inferLlmProvider($model) : null); - try { - $app = Apps::getById($deployment->apps_id); - $company = Companies::getById($deployment->companies_id); - } catch (Throwable) { - Log::warning("OtlpUsageIngestionService: could not load app/company for deployment #{$deployment->getId()}"); - - return; - } + $app = Apps::getById($deployment->apps_id); + $company = Companies::getById($deployment->companies_id); $source = ($provider !== null ? strtolower($provider) : 'agent') . '_otel'; @@ -171,8 +250,8 @@ private function processResourceMetrics(array $rm): void 'cache_write_tokens' => $tokens['cache_write_tokens'], 'provider' => $provider, 'model' => $model, - 'total_sessions' => 0, // Sessions tracked separately via telemetry service - 'raw_output' => null, + 'total_sessions' => 0, + 'raw_output' => '', 'parsed_data' => $tokens, ] ); From 2defbf26a0cdfa2a3becf8bd802ad4b7b12498f8 Mon Sep 17 00:00:00 2001 From: Rafael White Date: Thu, 11 Jun 2026 08:27:42 -0400 Subject: [PATCH 11/13] feat: add otel-collector service to development docker-compose Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.development.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 5345110161..c1e97c14e1 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -193,6 +193,23 @@ services: networks: - sail + otel-collector: + image: otel/opentelemetry-collector-contrib:0.102.0 + container_name: otel-collector + command: ["--config=/etc/otel/config.yaml"] + environment: + OTEL_INTERNAL_TOKEN: "${OTEL_INTERNAL_TOKEN}" + volumes: + - ./docker/otel/collector.yaml:/etc/otel/config.yaml:ro + ports: + - "4317:4317" + - "4318:4318" + networks: + - sail + depends_on: + - php + restart: unless-stopped + nginx: image: nginx:latest container_name: nginx${APP_CONTAINER_NAME} From 7b14bc8579fc2735b3c8dcff92ca31aa87e2417b Mon Sep 17 00:00:00 2001 From: Rafael White Date: Thu, 11 Jun 2026 08:29:03 -0400 Subject: [PATCH 12/13] fix: make Intelligence migrations idempotent with hasColumn guards - seed_default_tool_categories: add uuid generation to inserts - swap_tool_category_enum_for_fk: guard ADD COLUMN and DROP COLUMN with hasColumn checks - add_is_sub_agent: guard both column additions with hasColumn checks Co-Authored-By: Claude Sonnet 4.6 --- ...15_130000_seed_default_tool_categories.php | 1 + ..._140000_swap_tool_category_enum_for_fk.php | 20 +++++++++++-------- ...agent_to_agents_and_agents_id_to_tools.php | 18 ++++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php b/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php index 759d38c9dd..b1d0faa46e 100644 --- a/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php +++ b/database/migrations/Intelligence/2026_05_15_130000_seed_default_tool_categories.php @@ -48,6 +48,7 @@ public function up(): void continue; } DB::connection('intelligence')->table('nervous_system_tool_categories')->insert([ + 'uuid' => \Illuminate\Support\Str::uuid()->toString(), 'apps_id' => 0, 'slug' => $category['slug'], 'name' => $category['name'], diff --git a/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php b/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php index e48fbe899e..297871ad79 100644 --- a/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php +++ b/database/migrations/Intelligence/2026_05_15_140000_swap_tool_category_enum_for_fk.php @@ -12,10 +12,12 @@ public function up(): void { - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->unsignedBigInteger('tool_category_id')->nullable()->after('category'); - $table->index('tool_category_id', 'idx_tools_category_id'); - }); + if (! Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'tool_category_id')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->unsignedBigInteger('tool_category_id')->nullable()->after('category'); + $table->index('tool_category_id', 'idx_tools_category_id'); + }); + } // Backfill: map the existing enum string → category row id. Platform // categories live on apps_id=0, so the lookup ignores apps_id. @@ -32,10 +34,12 @@ public function up(): void ->update(['tool_category_id' => $id]); } - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->dropIndex('idx_tools_category'); - $table->dropColumn('category'); - }); + if (Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'category')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->dropIndex('idx_tools_category'); + $table->dropColumn('category'); + }); + } } public function down(): void diff --git a/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php b/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php index 1d7218a17a..40901e1e32 100644 --- a/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php +++ b/database/migrations/Intelligence/2026_05_27_000001_add_is_sub_agent_to_agents_and_agents_id_to_tools.php @@ -11,14 +11,18 @@ public function up(): void { - Schema::table('agents', function (Blueprint $table) { - $table->boolean('is_sub_agent')->default(false)->after('is_active'); - }); + if (! Schema::connection('intelligence')->hasColumn('agents', 'is_sub_agent')) { + Schema::table('agents', function (Blueprint $table) { + $table->boolean('is_sub_agent')->default(false)->after('is_active'); + }); + } - Schema::table('nervous_system_tools', function (Blueprint $table) { - $table->unsignedBigInteger('agents_id')->nullable()->after('tool_category_id'); - $table->foreign('agents_id')->references('id')->on('agents')->nullOnDelete(); - }); + if (! Schema::connection('intelligence')->hasColumn('nervous_system_tools', 'agents_id')) { + Schema::table('nervous_system_tools', function (Blueprint $table) { + $table->unsignedBigInteger('agents_id')->nullable(); + $table->foreign('agents_id')->references('id')->on('agents')->nullOnDelete(); + }); + } } public function down(): void From 9424a3e482bba035e630d12443b637cf199cf45b Mon Sep 17 00:00:00 2001 From: Rafael White Date: Thu, 11 Jun 2026 09:01:52 -0400 Subject: [PATCH 13/13] docs: add kanvas-init-env and sync-kanvas-db Claude Code skills Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/kanvas-init-env/SKILL.md | 193 +++++++++++++++++++ .claude/skills/sync-intelligence-db/SKILL.md | 140 ++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 .claude/skills/kanvas-init-env/SKILL.md create mode 100644 .claude/skills/sync-intelligence-db/SKILL.md diff --git a/.claude/skills/kanvas-init-env/SKILL.md b/.claude/skills/kanvas-init-env/SKILL.md new file mode 100644 index 0000000000..7c55f76a76 --- /dev/null +++ b/.claude/skills/kanvas-init-env/SKILL.md @@ -0,0 +1,193 @@ +--- +name: kanvas-init-env +description: > + Initializes a fresh Kanvas local environment: creates a new App, registers an + admin user (which auto-creates the Company and Company Branch), and returns + the API key + auth token needed to start testing. Invoke whenever the user + says "init env", "setup local", "create new app", "bootstrap kanvas", + "new local environment", "spin up kanvas", or wants to start from scratch + on a fresh Kanvas install. +--- + +# kanvas-init-env + +Bootstraps a new Kanvas local environment inside the `phpkanvas-ecosystem` +Docker container. Creates: App → registers Admin User (auto-creates Company + +Branch) → returns credentials. + +**Prerequisites:** `kanvas:setup-ecosystem` must have already been run (all +migrations applied, base seeders done). If not, run the `sync-kanvas-db` +skill first. + +--- + +## Step 1 — Verify the ecosystem is seeded + +Check that the default Kanvas app (id=1) and an owner user (id=1) exist, +since `kanvas:app-create` requires an existing user as owner: + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +echo 'Users: ' . \Kanvas\Users\Models\Users::count() . PHP_EOL; +echo 'Apps: ' . \Kanvas\Apps\Models\Apps::count() . PHP_EOL; +" +``` + +If counts are 0, the base seeders haven't run. Run: + +```bash +docker exec phpkanvas-ecosystem php artisan db:seed +``` + +--- + +## Step 2 — Create the App + +Ask the user for (or use these defaults if they say "use defaults"): +- **name** e.g. `"Local Test App"` +- **domain** e.g. `"localhost"` +- **url** e.g. `"http://localhost"` +- **owner email** — the existing user who will own this app (default: the + seeded user, usually `admin@kantvasapp.com` or `anonymous@kanvas.dev`) +- **ecosystem_auth** — `true` (share auth with Kanvas ecosystem) +- **payments_active** — `false` for local testing + +Create via tinker (non-interactive, scriptable): + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +use Kanvas\Apps\Actions\CreateAppsAction; +use Kanvas\Apps\DataTransferObject\AppInput; +use Kanvas\Users\Models\Users; + +\$owner = Users::first(); +\$data = AppInput::from([ + 'name' => 'Local Test App', + 'url' => 'http://localhost', + 'description' => 'Local development app', + 'domain' => 'localhost', + 'is_actived' => 1, + 'ecosystem_auth' => 1, + 'payments_active' => 0, + 'is_public' => 1, + 'domain_based' => 0, +]); +\$app = (new CreateAppsAction(\$data, \$owner))->execute(); +echo 'App created!' . PHP_EOL; +echo 'ID: ' . \$app->id . PHP_EOL; +echo 'Key: ' . \$app->key . PHP_EOL; +" 2>/dev/null +``` + +Save the **API Key** from the output — every subsequent request needs it as +the `X-Kanvas-Key` header. + +Alternatively, run the interactive artisan command: + +```bash +docker exec -it phpkanvas-ecosystem php artisan kanvas:app-create +``` + +--- + +## Step 3 — Register the Admin User (auto-creates Company + Branch) + +The `register` mutation creates the user, a default Company, and a default +Company Branch in one call. The first user registered in an app gets the +`Admin` role by default. + +Replace `` with the key from Step 2: + +```bash +curl -s -X POST http://localhost/graphql \ + -H "Content-Type: application/json" \ + -H "X-Kanvas-Key: " \ + -d '{ + "query": "mutation Register($data: RegisterInput!) { register(data: $data) { user { id uuid email default_company } token { token refresh_token token_expires } } }", + "variables": { + "data": { + "firstname": "Admin", + "lastname": "User", + "displayname": "admin", + "email": "admin@localtest.com", + "password": "Test1234!", + "password_confirmation": "Test1234!", + "company_name": "Local Test Company" + } + } + }' | python3 -m json.tool 2>/dev/null || \ +curl -s -X POST http://localhost/graphql \ + -H "Content-Type: application/json" \ + -H "X-Kanvas-Key: " \ + -d '{"query":"mutation { register(data: { firstname: \"Admin\", lastname: \"User\", displayname: \"admin\", email: \"admin@localtest.com\", password: \"Test1234!\", password_confirmation: \"Test1234!\" }) { user { id email } token { token } } }"}' +``` + +Save from the response: +- `user.id` — the admin user ID +- `token.token` — Bearer token for authenticated requests +- `user.default_company` — the auto-created company ID + +--- + +## Step 4 — Verify entities were created + +```bash +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +use Kanvas\Apps\Models\Apps; +use Kanvas\Users\Models\Users; +use Kanvas\Companies\Models\Companies; +use Kanvas\Companies\Models\CompaniesBranches; + +\$app = Apps::latest()->first(); +echo '=== App ===' . PHP_EOL; +echo 'Name: ' . \$app->name . PHP_EOL; +echo 'Key: ' . \$app->key . PHP_EOL; + +\$user = Users::getByEmail('admin@localtest.com'); +echo PHP_EOL . '=== User ===' . PHP_EOL; +echo 'ID: ' . \$user->id . PHP_EOL; +echo 'Email: ' . \$user->email . PHP_EOL; + +\$company = Companies::find(\$user->default_company); +echo PHP_EOL . '=== Company ===' . PHP_EOL; +echo 'Name: ' . \$company->name . PHP_EOL; + +\$branch = CompaniesBranches::where('companies_id', \$company->id)->first(); +echo PHP_EOL . '=== Branch ===' . PHP_EOL; +echo 'Name: ' . \$branch->name . PHP_EOL; +" 2>/dev/null +``` + +--- + +## Step 5 — Report credentials to the user + +Present a summary block they can copy into `.env` or pass to the admin UI: + +``` +Environment ready +───────────────────────────────────────── +App Name: Local Test App +App Key: ← X-Kanvas-Key header / NEXT_PUBLIC_KANVAS_KEY +───────────────────────────────────────── +Admin Email: admin@localtest.com +Admin Password: Test1234! +Bearer Token: ← Authorization: Bearer +───────────────────────────────────────── +Company: Local Test Company (id: N) +Branch: Default Branch (id: N) +───────────────────────────────────────── +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `No query results for model [Users]` on app create | No users seeded yet | Run `docker exec phpkanvas-ecosystem php artisan db:seed` first | +| `Email has already been taken` | User registered in this app already | Change the email or use a different app key | +| `422 / validation error` on register | `displayname` already taken globally | Change the displayname | +| `Role not found` | Roles not created | Run `docker exec phpkanvas-ecosystem php artisan kanvas:create-role Admin` | +| GraphQL returns `Unauthenticated` | Wrong or missing `X-Kanvas-Key` | Double-check the key from Step 2 | +| `curl: connection refused` | API not running | Make sure Docker containers are up: `docker compose up -d` | diff --git a/.claude/skills/sync-intelligence-db/SKILL.md b/.claude/skills/sync-intelligence-db/SKILL.md new file mode 100644 index 0000000000..09f687e931 --- /dev/null +++ b/.claude/skills/sync-intelligence-db/SKILL.md @@ -0,0 +1,140 @@ +--- +name: sync-kanvas-db +description: > + Runs pending database migrations across ALL Kanvas Laravel API databases on + the local Docker environment. Invoke whenever the user says "sync db", + "run migrations", "sync database", "pull from dev", "run pending migrations", + or when any page returns a 500/GraphQL error caused by a missing table or + column. Also invoke proactively after any `git pull` or `git merge` from + dev/main when migration files have changed. +--- + +# sync-kanvas-db + +Applies pending migrations to every database inside the `phpkanvas-ecosystem` +Docker container. Kanvas uses multiple separate MySQL databases, each with its +own migration subfolder and Laravel connection name. + +## Container and paths + +- **Container**: `phpkanvas-ecosystem` +- **API repo (host)**: `/Users/mcwhite/Documents/docker_projects/kanvas/kanvas-ecosystem-api` +- **Migrations root (inside container)**: `database/migrations/` + +## Database map + +Each subfolder uses a different Laravel connection. Run migrations for the +subfolder with the matching `--database` flag. + +| Migration path | `--database` flag | Notes | +|---|---|---| +| `database/migrations/` (root) | *(omit — default mysql)* | Core platform tables | +| `database/migrations/ActionEngine/` | `--database=action_engine` | | +| `database/migrations/Event/` | `--database=event` | | +| `database/migrations/Guild/` | `--database=crm` | CRM module | +| `database/migrations/Intelligence/` | `--database=intelligence` | AI agents, tools, deployments | +| `database/migrations/Inventory/` | `--database=inventory` | | +| `database/migrations/Social/` | `--database=social` | | +| `database/migrations/Souk/` | `--database=commerce` | Commerce/orders | +| `database/migrations/Subscription/` | *(omit — default mysql)* | Subscription tables in main DB | +| `database/migrations/Workflow/` | `--database=workflow` | | + +## Step 1 — Find pending migrations + +Run this for each path you care about (or all of them after a big pull): + +```bash +# For a specific path: +docker exec phpkanvas-ecosystem php artisan migrate:status \ + --path=database/migrations/Intelligence --database=intelligence 2>/dev/null \ + | grep "Pending" + +# For the default-connection paths (root, ActionEngine, Subscription): +docker exec phpkanvas-ecosystem php artisan migrate:status \ + --path=database/migrations 2>/dev/null | grep "Pending" +``` + +Filter results to only entries under the path you queried — artisan sometimes +shows migrations from the whole project. + +## Step 2 — Run each pending migration individually + +Run in chronological order (filename = timestamp order), one at a time. + +```bash +docker exec phpkanvas-ecosystem php artisan migrate \ + --path= \ + [--database=] \ + --force 2>&1 +``` + +### Handling failures gracefully + +These errors mean the schema already diverged on your local DB — skip, don't +abort: + +| Error | Action | +|---|---| +| `Column already exists` (1060) | Schema already present. Fake-mark (Step 3) and continue. | +| `Table already exists` (1050) | Table already present. Fake-mark (Step 3) and continue. | +| `Duplicate key name` (1061) | Index already exists. Fake-mark (Step 3) and continue. | +| `Field 'uuid' doesn't have a default value` (1364) | Seed migration omits `uuid`. Fix: add `'uuid' => \Illuminate\Support\Str::uuid()->toString()` to each insert in the migration file, then re-run. | +| `Column not found` (1054) after `->after(...)` | The referenced column doesn't exist locally. Fix: remove the `->after('...')` call in the migration file, then re-run. | +| Any other error | Log it and continue to the next migration. | + +When editing a migration file to fix a local divergence, make the change +minimally (remove `->after()`, add `uuid`, wrap in `hasColumn()` guard). Don't +commit these fixups unless asked. + +## Step 3 — Fake-mark already-applied migrations + +If the schema change already exists but the migration record doesn't, insert a +fake record so Laravel stops reporting it as Pending. + +**IMPORTANT**: you must insert into the correct DB's `migrations` table — match +the `--database` connection from the table above. + +```bash +# Example for intelligence DB: +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +DB::connection('intelligence')->table('migrations')->insertOrIgnore([ + 'migration' => '2026_05_15_000000_migration_name_without_php', + 'batch' => 99 +]); +" + +# Example for default mysql DB: +docker exec phpkanvas-ecosystem php artisan tinker --execute=" +DB::table('migrations')->insertOrIgnore([ + 'migration' => '2026_05_15_000000_migration_name_without_php', + 'batch' => 99 +]); +" +``` + +Use only when the schema change definitely exists but the migration keeps +showing as Pending. + +## Step 4 — Report + +After processing all paths: + +``` +Kanvas DB sync complete +✓ Ran: +⚠ Skipped: +✗ Failed: +``` + +If nothing was pending, say so and exit cleanly. + +## Common missing tables (reference) + +These are frequently missing on local DBs that haven't been fully migrated: + +| Table | DB | Migration file | +|---|---|---| +| `agents_kanvas_modules` | `intelligence` | `2026_05_15_040000_create_agents_kanvas_modules.php` | +| `agent_daily_cycles` | `intelligence` | `2026_05_15_080000_create_agent_daily_cycles.php` | +| `agent_sleep_phases` | `intelligence` | `2026_05_15_090000_create_agent_sleep_phases.php` | +| `nervous_system_tool_categories` | `intelligence` | `2026_05_15_120000_create_nervous_system_tool_categories.php` |