Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.72% |
154 / 156 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
| OtlpController | |
98.72% |
154 / 156 |
|
85.71% |
6 / 7 |
47 | |
0.00% |
0 / 1 |
| ingestMetrics | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
10 | |||
| ingestLogs | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
7 | |||
| extractAttributes | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| cleanAttributes | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| upsertSession | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
9 | |||
| resolveSessionGroupId | |
93.33% |
28 / 30 |
|
0.00% |
0 / 1 |
10.03 | |||
| nanoToCarbon | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Http\Controllers; |
| 4 | |
| 5 | use App\Models\TelemetryEvent; |
| 6 | use App\Models\TelemetryMetric; |
| 7 | use App\Models\TelemetrySession; |
| 8 | use Carbon\Carbon; |
| 9 | use Illuminate\Http\JsonResponse; |
| 10 | use Illuminate\Http\Request; |
| 11 | use Illuminate\Support\Facades\Log; |
| 12 | use Illuminate\Support\Str; |
| 13 | |
| 14 | class 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 | } |