Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.72% covered (success)
98.72%
154 / 156
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
OtlpController
98.72% covered (success)
98.72%
154 / 156
85.71% covered (warning)
85.71%
6 / 7
47
0.00% covered (danger)
0.00%
0 / 1
 ingestMetrics
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
10
 ingestLogs
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 extractAttributes
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 cleanAttributes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 upsertSession
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 resolveSessionGroupId
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
10.03
 nanoToCarbon
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\TelemetryEvent;
6use App\Models\TelemetryMetric;
7use App\Models\TelemetrySession;
8use Carbon\Carbon;
9use Illuminate\Http\JsonResponse;
10use Illuminate\Http\Request;
11use Illuminate\Support\Facades\Log;
12use Illuminate\Support\Str;
13
14class OtlpController extends Controller
15{
16    private const SESSION_META_KEYS = [
17        'session.id',
18        'user.email',
19        'user.id',
20        'user.account_uuid',
21        'organization.id',
22        'terminal.type',
23        'project.name',
24        'billing.model',
25    ];
26
27    private const MAX_ATTRIBUTE_LENGTH = 1000;
28
29    public function ingestMetrics(Request $request): JsonResponse
30    {
31        $payload = $request->json()->all();
32
33        try {
34            foreach ($payload['resourceMetrics'] ?? [] as $resourceMetric) {
35                $resourceAttrs = $this->extractAttributes(
36                    $resourceMetric['resource']['attributes'] ?? []
37                );
38
39                $sessionId = null;
40
41                foreach ($resourceMetric['scopeMetrics'] ?? [] as $scopeMetric) {
42                    foreach ($scopeMetric['metrics'] ?? [] as $metric) {
43                        $name = $metric['name'];
44                        $unit = $metric['unit'] ?? null;
45
46                        $dataPoints = [];
47                        $metricType = 'unknown';
48
49                        if (isset($metric['sum'])) {
50                            $dataPoints = $metric['sum']['dataPoints'] ?? [];
51                            $metricType = 'sum';
52                        } elseif (isset($metric['gauge'])) {
53                            $dataPoints = $metric['gauge']['dataPoints'] ?? [];
54                            $metricType = 'gauge';
55                        } elseif (isset($metric['histogram'])) {
56                            $dataPoints = $metric['histogram']['dataPoints'] ?? [];
57                            $metricType = 'histogram';
58                        }
59
60                        foreach ($dataPoints as $dp) {
61                            $value = $dp['asDouble'] ?? $dp['asInt'] ?? $dp['sum'] ?? 0;
62                            $time = $this->nanoToCarbon($dp['timeUnixNano'] ?? $dp['startTimeUnixNano'] ?? '0');
63                            $attrs = $this->extractAttributes($dp['attributes'] ?? []);
64
65                            if ($sessionId === null) {
66                                $merged = array_merge($resourceAttrs, $attrs);
67                                $sessionId = $this->upsertSession($merged);
68                            }
69
70                            TelemetryMetric::create([
71                                'session_id' => $sessionId,
72                                'metric_name' => $name,
73                                'metric_type' => $metricType,
74                                'value' => (float) $value,
75                                'unit' => $unit,
76                                'attributes' => $this->cleanAttributes($attrs),
77                                'recorded_at' => $time,
78                            ]);
79                        }
80                    }
81                }
82            }
83        } catch (\Throwable $e) {
84            Log::error('OTLP metrics ingestion failed', ['error' => $e->getMessage()]);
85
86            return response()->json(['partialSuccess' => ['rejectedDataPoints' => 1, 'errorMessage' => 'Ingestion error']], 200);
87        }
88
89        return response()->json(['partialSuccess' => new \stdClass()]);
90    }
91
92    public function ingestLogs(Request $request): JsonResponse
93    {
94        $payload = $request->json()->all();
95
96        try {
97            foreach ($payload['resourceLogs'] ?? [] as $resourceLog) {
98                $resourceAttrs = $this->extractAttributes(
99                    $resourceLog['resource']['attributes'] ?? []
100                );
101
102                $sessionId = null;
103
104                foreach ($resourceLog['scopeLogs'] ?? [] as $scopeLog) {
105                    foreach ($scopeLog['logRecords'] ?? [] as $record) {
106                        $attrs = $this->extractAttributes($record['attributes'] ?? []);
107
108                        if ($sessionId === null) {
109                            $merged = array_merge($resourceAttrs, $attrs);
110                            $sessionId = $this->upsertSession($merged);
111                        }
112
113                        $eventName = $attrs['event.name'] ?? $record['body']['stringValue'] ?? 'unknown';
114                        unset($attrs['event.name']);
115
116                        $time = $this->nanoToCarbon($record['timeUnixNano'] ?? $record['observedTimeUnixNano'] ?? '0');
117                        $body = null;
118                        if (isset($record['body'])) {
119                            $body = $record['body']['stringValue'] ?? json_encode($record['body']);
120                        }
121                        $severity = $record['severityText'] ?? null;
122
123                        TelemetryEvent::create([
124                            'session_id' => $sessionId,
125                            'event_name' => $eventName,
126                            'severity' => $severity,
127                            'body' => $body,
128                            'attributes' => $this->cleanAttributes($attrs),
129                            'recorded_at' => $time,
130                        ]);
131                    }
132                }
133            }
134        } catch (\Throwable $e) {
135            Log::error('OTLP logs ingestion failed', ['error' => $e->getMessage()]);
136
137            return response()->json(['partialSuccess' => ['rejectedLogRecords' => 1, 'errorMessage' => 'Ingestion error']], 200);
138        }
139
140        return response()->json(['partialSuccess' => new \stdClass()]);
141    }
142
143    private function extractAttributes(array $attributes): array
144    {
145        $result = [];
146        foreach ($attributes as $attr) {
147            $key = $attr['key'] ?? null;
148            $value = $attr['value'] ?? [];
149            if ($key === null) {
150                continue;
151            }
152            $resolved = $value['stringValue']
153                ?? $value['intValue']
154                ?? $value['doubleValue']
155                ?? ($value['boolValue'] ?? null)
156                ?? json_encode($value);
157
158            if (is_string($resolved) && mb_strlen($resolved) > self::MAX_ATTRIBUTE_LENGTH) {
159                $resolved = mb_substr($resolved, 0, self::MAX_ATTRIBUTE_LENGTH);
160            }
161
162            $result[$key] = $resolved;
163        }
164
165        return $result;
166    }
167
168    private function cleanAttributes(array $attrs): ?array
169    {
170        foreach (self::SESSION_META_KEYS as $key) {
171            unset($attrs[$key]);
172        }
173
174        return $attrs ?: null;
175    }
176
177    private function upsertSession(array $attrs): string
178    {
179        $sessionId = $attrs['session.id'] ?? 'unknown-'.uniqid();
180        $now = now();
181
182        $meta = [
183            'user_email' => $attrs['user.email'] ?? null,
184            'user_id' => $attrs['user.id'] ?? null,
185            'account_uuid' => $attrs['user.account_uuid'] ?? null,
186            'organization_id' => $attrs['organization.id'] ?? null,
187            'app_version' => $attrs['app.version'] ?? $attrs['service.version'] ?? null,
188            'terminal_type' => $attrs['terminal.type'] ?? null,
189            'project_name' => $attrs['project.name'] ?? null,
190            'billing_model' => $attrs['billing.model'] ?? null,
191            'hostname' => null,
192        ];
193
194        $cacheKey = "pending_session_meta:{$sessionId}";
195        $pendingMeta = cache()->get($cacheKey);
196        if ($pendingMeta) {
197            if (! $meta['project_name'] && ! empty($pendingMeta['project_name'])) {
198                $meta['project_name'] = $pendingMeta['project_name'];
199            }
200            if (empty($meta['hostname']) && ! empty($pendingMeta['hostname'])) {
201                $meta['hostname'] = $pendingMeta['hostname'];
202            }
203        }
204
205        $session = TelemetrySession::where('session_id', $sessionId)->first();
206
207        if ($session) {
208            $session->update(array_filter($meta) + ['last_seen_at' => $now]);
209        } else {
210            if (! $meta['project_name']) {
211                $meta['project_name'] = 'background';
212            }
213
214            $groupId = $this->resolveSessionGroupId($meta);
215
216            TelemetrySession::create(
217                ['session_id' => $sessionId, 'session_group_id' => $groupId, 'first_seen_at' => $now, 'last_seen_at' => $now] + $meta
218            );
219
220            if ($pendingMeta) {
221                cache()->forget($cacheKey);
222            }
223        }
224
225        return $sessionId;
226    }
227
228    private function resolveSessionGroupId(array $meta): ?string
229    {
230        $userId = $meta['user_id'] ?? null;
231        $userEmail = $meta['user_email'] ?? null;
232        $projectName = $meta['project_name'] ?? null;
233
234        if (! $projectName) {
235            return null;
236        }
237
238        if (! $userId && ! $userEmail) {
239            return null;
240        }
241
242        $userScope = function ($q) use ($userId, $userEmail) {
243            if ($userId && $userEmail) {
244                $q->where('user_id', $userId)->orWhere('user_email', $userEmail);
245            } elseif ($userId) {
246                $q->where('user_id', $userId);
247            } else {
248                $q->where('user_email', $userEmail);
249            }
250        };
251
252        $baseQuery = fn () => TelemetrySession::where('project_name', $projectName)
253            ->where($userScope);
254
255        // Background sessions only group with peers confirmed as background
256        // (first_seen_at > 5 min ago = hook had its chance and didn't update)
257        if ($projectName === 'background') {
258            $window = config('claude-board.session_group_window', 5);
259            $baseQuery = fn () => TelemetrySession::where('project_name', 'background')
260                ->where('first_seen_at', '<', now()->subMinutes($window))
261                ->where($userScope);
262        }
263
264        $existingSession = $baseQuery()->whereNotNull('session_group_id')->first();
265
266        if ($existingSession) {
267            return $existingSession->session_group_id;
268        }
269
270        $ungroupedSession = $baseQuery()->whereNull('session_group_id')->first();
271
272        if ($ungroupedSession) {
273            $newGroupId = (string) Str::ulid();
274            $ungroupedSession->update(['session_group_id' => $newGroupId]);
275
276            return $newGroupId;
277        }
278
279        return (string) Str::ulid();
280    }
281
282    private function nanoToCarbon(string $nanoTimestamp): Carbon
283    {
284        if ($nanoTimestamp === '0' || empty($nanoTimestamp)) {
285            return now();
286        }
287
288        $seconds = (int) ($nanoTimestamp / 1_000_000_000);
289
290        return Carbon::createFromTimestamp($seconds);
291    }
292}