• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2553

19 Dec 2025 05:28PM UTC coverage: 89.483% (+0.01%) from 89.472%
#2553

push

php-coveralls

edwh
Add Freegle logging context headers to Loki

Extract X-Freegle-Session, X-Freegle-Page, X-Freegle-Modal, and
X-Freegle-Site headers from API requests and include them in log
entries. This enables hierarchical context capture for improved
log analysis and debugging.

0 of 23 new or added lines in 1 file covered. (0.0%)

3 existing lines in 1 file now uncovered.

26605 of 29732 relevant lines covered (89.48%)

31.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

3.81
/include/misc/Loki.php
1
<?php
2
namespace Freegle\Iznik;
3

4
/**
5
 * Loki client for sending logs to Grafana Loki via JSON files.
6
 *
7
 * Logs are written to JSON files which Alloy ships to Loki.
8
 * This approach is resilient (survives Loki downtime) and non-blocking.
9
 */
10
class Loki
11
{
12
    private static $instance = NULL;
13
    private $enabled = FALSE;
14
    private $jsonLogPath = '/var/log/freegle';
15

16
    private function __construct()
17
    {
18
        // Read from constants defined in /etc/iznik.conf.
19
        $this->enabled = defined('LOKI_ENABLED') && LOKI_ENABLED;
1✔
20
        $jsonPath = defined('LOKI_JSON_PATH') ? LOKI_JSON_PATH : NULL;
1✔
21

22
        if ($this->enabled && empty($jsonPath)) {
1✔
23
            error_log('Loki enabled but LOKI_JSON_PATH not set, disabling Loki');
1✔
24
            $this->enabled = FALSE;
1✔
25
        } elseif (!empty($jsonPath)) {
×
26
            $this->jsonLogPath = $jsonPath;
×
27
        }
28
    }
29

30
    public static function getInstance()
31
    {
32
        if (self::$instance === NULL) {
643✔
33
            self::$instance = new Loki();
1✔
34
        }
35
        return self::$instance;
643✔
36
    }
37

38
    /**
39
     * Check if Loki logging is enabled.
40
     */
41
    public function isEnabled()
42
    {
43
        return $this->enabled;
643✔
44
    }
45

46
    /**
47
     * Headers to exclude from logging for security reasons.
48
     */
49
    private static $sensitiveHeaderPatterns = [
50
        '/^authorization$/i',
51
        '/^cookie$/i',
52
        '/^set-cookie$/i',
53
        '/token/i',
54
        '/key/i',
55
        '/secret/i',
56
        '/password/i',
57
        '/^x-api-key$/i',
58
    ];
59

60
    /**
61
     * Headers to include in logging (allowlist approach for request headers).
62
     */
63
    private static $allowedRequestHeaders = [
64
        'user-agent',
65
        'referer',
66
        'content-type',
67
        'accept',
68
        'accept-language',
69
        'accept-encoding',
70
        'x-forwarded-for',
71
        'x-forwarded-proto',
72
        'x-request-id',
73
        'x-real-ip',
74
        'origin',
75
        'host',
76
        'content-length',
77
        'x-trace-id',
78
        'x-session-id',
79
        'x-client-timestamp',
80
        // Logging context headers.
81
        'x-freegle-session',
82
        'x-freegle-page',
83
        'x-freegle-modal',
84
        'x-freegle-site',
85
    ];
86

87
    /**
88
     * Get trace and context headers from the current request.
89
     *
90
     * @return array Trace and context information from headers
91
     */
92
    public function getTraceHeaders()
93
    {
94
        $headers = [];
×
95

96
        // Header mappings: header name (lowercase) => output key.
NEW
97
        $headerMappings = [
×
98
            // Legacy trace headers.
NEW
99
            'x-trace-id' => 'trace_id',
×
NEW
100
            'x-session-id' => 'session_id',
×
NEW
101
            'x-client-timestamp' => 'client_timestamp',
×
102
            // New logging context headers.
NEW
103
            'x-freegle-session' => 'freegle_session',
×
NEW
104
            'x-freegle-page' => 'freegle_page',
×
NEW
105
            'x-freegle-modal' => 'freegle_modal',
×
NEW
106
            'x-freegle-site' => 'freegle_site',
×
NEW
107
        ];
×
108

109
        // Get headers from Apache headers or $_SERVER.
110
        if (function_exists('apache_request_headers')) {
×
111
            $requestHeaders = apache_request_headers();
×
112
            foreach ($requestHeaders as $name => $value) {
×
113
                $nameLower = strtolower($name);
×
NEW
114
                if (isset($headerMappings[$nameLower])) {
×
NEW
115
                    $headers[$headerMappings[$nameLower]] = $value;
×
116
                }
117
            }
118
        }
119

120
        // Fallback to $_SERVER for any missing headers.
NEW
121
        $serverMappings = [
×
NEW
122
            'HTTP_X_TRACE_ID' => 'trace_id',
×
NEW
123
            'HTTP_X_SESSION_ID' => 'session_id',
×
NEW
124
            'HTTP_X_CLIENT_TIMESTAMP' => 'client_timestamp',
×
NEW
125
            'HTTP_X_FREEGLE_SESSION' => 'freegle_session',
×
NEW
126
            'HTTP_X_FREEGLE_PAGE' => 'freegle_page',
×
NEW
127
            'HTTP_X_FREEGLE_MODAL' => 'freegle_modal',
×
NEW
128
            'HTTP_X_FREEGLE_SITE' => 'freegle_site',
×
NEW
129
        ];
×
130

NEW
131
        foreach ($serverMappings as $serverKey => $outputKey) {
×
NEW
132
            if (empty($headers[$outputKey]) && !empty($_SERVER[$serverKey])) {
×
NEW
133
                $headers[$outputKey] = $_SERVER[$serverKey];
×
134
            }
135
        }
136

137
        return $headers;
×
138
    }
139

140
    /**
141
     * Maximum string length for logged values.
142
     */
143
    const MAX_STRING_LENGTH = 32;
144

145
    /**
146
     * Truncate a string to MAX_STRING_LENGTH characters.
147
     *
148
     * @param string $value String to truncate
149
     * @return string Truncated string
150
     */
151
    private function truncateString($value)
152
    {
153
        if (strlen($value) <= self::MAX_STRING_LENGTH) {
×
154
            return $value;
×
155
        }
156
        return substr($value, 0, self::MAX_STRING_LENGTH) . '...';
×
157
    }
158

159
    /**
160
     * Recursively truncate all string values in an array.
161
     *
162
     * @param mixed $data Data to truncate
163
     * @return mixed Truncated data
164
     */
165
    private function truncateData($data)
166
    {
167
        if (is_string($data)) {
×
168
            return $this->truncateString($data);
×
169
        }
170

171
        if (is_array($data)) {
×
172
            $result = [];
×
173
            foreach ($data as $key => $value) {
×
174
                $result[$key] = $this->truncateData($value);
×
175
            }
176
            return $result;
×
177
        }
178

179
        return $data;
×
180
    }
181

182
    /**
183
     * Log API request to Loki.
184
     *
185
     * @param string $version API version (v1 or v2)
186
     * @param string $method HTTP method
187
     * @param string $endpoint API endpoint
188
     * @param int $statusCode HTTP status code
189
     * @param float $duration Request duration in milliseconds
190
     * @param int|null $userId User ID if authenticated
191
     * @param array $extra Extra fields to log
192
     */
193
    public function logApiRequest($version, $method, $endpoint, $statusCode, $duration, $userId = NULL, $extra = [])
194
    {
195
        if (!$this->enabled) {
×
196
            return;
×
197
        }
198

199
        $labels = [
×
200
            'app' => 'freegle',
×
201
            'source' => 'api',
×
202
            'api_version' => $version,
×
203
            'method' => $method,
×
204
            'status_code' => (string)$statusCode,
×
205
        ];
×
206

207
        // Include trace headers for distributed tracing correlation.
208
        $traceHeaders = $this->getTraceHeaders();
×
209

210
        $logLine = array_merge([
×
211
            'endpoint' => $endpoint,
×
212
            'duration_ms' => $duration,
×
213
            'user_id' => $userId,
×
214
            'timestamp' => date('c'),
×
215
        ], $traceHeaders, $extra);
×
216

217
        $this->log($labels, $logLine);
×
218
    }
219

220
    /**
221
     * Log API request with full request/response data to Loki.
222
     *
223
     * @param string $version API version (v1 or v2)
224
     * @param string $method HTTP method
225
     * @param string $endpoint API endpoint
226
     * @param int $statusCode HTTP status code
227
     * @param float $duration Request duration in milliseconds
228
     * @param int|null $userId User ID if authenticated
229
     * @param array $extra Extra fields to log
230
     * @param array $queryParams Query parameters (will be truncated)
231
     * @param array|null $requestBody Request body (will be truncated)
232
     * @param array|null $responseBody Response body (will be truncated)
233
     */
234
    public function logApiRequestFull($version, $method, $endpoint, $statusCode, $duration, $userId = NULL, $extra = [], $queryParams = [], $requestBody = NULL, $responseBody = NULL)
235
    {
236
        if (!$this->enabled) {
×
237
            return;
×
238
        }
239

240
        $labels = [
×
241
            'app' => 'freegle',
×
242
            'source' => 'api',
×
243
            'api_version' => $version,
×
244
            'method' => $method,
×
245
            'status_code' => (string)$statusCode,
×
246
        ];
×
247

248
        // Include trace headers for distributed tracing correlation.
249
        $traceHeaders = $this->getTraceHeaders();
×
250

251
        $logLine = array_merge([
×
252
            'endpoint' => $endpoint,
×
253
            'duration_ms' => $duration,
×
254
            'user_id' => $userId,
×
255
            'timestamp' => date('c'),
×
256
        ], $traceHeaders, $extra);
×
257

258
        // Add query parameters (truncated).
259
        if (!empty($queryParams)) {
×
260
            $logLine['query_params'] = $this->truncateData($queryParams);
×
261
        }
262

263
        // Add request body (truncated).
264
        if (!empty($requestBody)) {
×
265
            $logLine['request_body'] = $this->truncateData($requestBody);
×
266
        }
267

268
        // Add response body (truncated).
269
        if (!empty($responseBody)) {
×
270
            $logLine['response_body'] = $this->truncateData($responseBody);
×
271
        }
272

273
        $this->log($labels, $logLine);
×
274
    }
275

276
    /**
277
     * Log API headers to Loki (separate stream with longer retention for debugging).
278
     *
279
     * @param string $version API version (v1 or v2)
280
     * @param string $method HTTP method
281
     * @param string $endpoint API endpoint
282
     * @param array $requestHeaders Request headers
283
     * @param array $responseHeaders Response headers
284
     * @param int|null $userId User ID if authenticated
285
     * @param string|null $requestId Unique request ID for correlation with main API log
286
     */
287
    public function logApiHeaders($version, $method, $endpoint, $requestHeaders, $responseHeaders, $userId = NULL, $requestId = NULL)
288
    {
289
        if (!$this->enabled) {
×
290
            return;
×
291
        }
292

293
        $labels = [
×
294
            'app' => 'freegle',
×
295
            'source' => 'api_headers',
×
296
            'api_version' => $version,
×
297
            'method' => $method,
×
298
        ];
×
299

300
        $logLine = [
×
301
            'endpoint' => $endpoint,
×
302
            'user_id' => $userId,
×
303
            'request_id' => $requestId,
×
304
            'request_headers' => $this->filterHeaders($requestHeaders, TRUE),
×
305
            'response_headers' => $this->filterHeaders($responseHeaders, FALSE),
×
306
            'timestamp' => date('c'),
×
307
        ];
×
308

309
        $this->log($labels, $logLine);
×
310
    }
311

312
    /**
313
     * Filter headers to remove sensitive information.
314
     *
315
     * @param array $headers Headers to filter
316
     * @param bool $useAllowlist Whether to use allowlist (for request headers) or just blocklist
317
     * @return array Filtered headers
318
     */
319
    private function filterHeaders($headers, $useAllowlist = FALSE)
320
    {
321
        $filtered = [];
×
322

323
        foreach ($headers as $name => $value) {
×
324
            $nameLower = strtolower($name);
×
325

326
            // Check against sensitive patterns.
327
            $isSensitive = FALSE;
×
328
            foreach (self::$sensitiveHeaderPatterns as $pattern) {
×
329
                if (preg_match($pattern, $name)) {
×
330
                    $isSensitive = TRUE;
×
331
                    break;
×
332
                }
333
            }
334

335
            if ($isSensitive) {
×
336
                continue;
×
337
            }
338

339
            // For request headers, use allowlist.
340
            if ($useAllowlist) {
×
341
                if (in_array($nameLower, self::$allowedRequestHeaders)) {
×
342
                    $filtered[$name] = is_array($value) ? implode(', ', $value) : $value;
×
343
                }
344
            } else {
345
                // For response headers, include all non-sensitive.
346
                $filtered[$name] = is_array($value) ? implode(', ', $value) : $value;
×
347
            }
348
        }
349

350
        return $filtered;
×
351
    }
352

353
    /**
354
     * Log email send to Loki.
355
     *
356
     * @param string $type Email type (digest, notification, etc.)
357
     * @param string $recipient Recipient email address
358
     * @param string $subject Email subject
359
     * @param int|null $userId User ID
360
     * @param int|null $groupId Group ID
361
     * @param array $extra Extra fields to log
362
     */
363
    public function logEmailSend($type, $recipient, $subject, $userId = NULL, $groupId = NULL, $extra = [])
364
    {
365
        if (!$this->enabled) {
×
366
            return;
×
367
        }
368

369
        $labels = [
×
370
            'app' => 'freegle',
×
371
            'source' => 'email',
×
372
            'email_type' => $type,
×
373
        ];
×
374

375
        if ($groupId) {
×
376
            $labels['groupid'] = (string)$groupId;
×
377
        }
378

379
        $logLine = array_merge([
×
380
            'recipient' => $this->hashEmail($recipient),
×
381
            'subject' => $subject,
×
382
            'user_id' => $userId,
×
383
            'group_id' => $groupId,
×
384
            'timestamp' => date('c'),
×
385
        ], $extra);
×
386

387
        $this->log($labels, $logLine);
×
388
    }
389

390
    /**
391
     * Log from the logs table (for dual-write).
392
     *
393
     * @param array $params Log parameters matching logs table columns
394
     */
395
    public function logFromLogsTable($params)
396
    {
397
        if (!$this->enabled) {
×
398
            return;
×
399
        }
400

401
        $labels = [
×
402
            'app' => 'freegle',
×
403
            'source' => 'logs_table',
×
404
            'type' => $params['type'] ?? 'unknown',
×
405
            'subtype' => $params['subtype'] ?? 'unknown',
×
406
        ];
×
407

408
        if (!empty($params['groupid'])) {
×
409
            $labels['groupid'] = (string)$params['groupid'];
×
410
        }
411

412
        $logLine = [
×
413
            'user' => $params['user'] ?? NULL,
×
414
            'byuser' => $params['byuser'] ?? NULL,
×
415
            'msgid' => $params['msgid'] ?? NULL,
×
416
            'groupid' => $params['groupid'] ?? NULL,
×
417
            'text' => $params['text'] ?? NULL,
×
418
            'configid' => $params['configid'] ?? NULL,
×
419
            'stdmsgid' => $params['stdmsgid'] ?? NULL,
×
420
            'bulkopid' => $params['bulkopid'] ?? NULL,
×
421
            'timestamp' => $params['timestamp'] ?? date('c'),
×
422
        ];
×
423

424
        $this->log($labels, $logLine);
×
425
    }
426

427
    /**
428
     * Send a log entry to Loki.
429
     *
430
     * @param array $labels Loki labels
431
     * @param array|string $logLine Log content
432
     */
433
    public function log($labels, $logLine)
434
    {
435
        $this->logWithTimestamp($labels, $logLine, NULL);
×
436
    }
437

438
    /**
439
     * Send a log entry to Loki with a specific timestamp (for historical backfill).
440
     *
441
     * @param array $labels Loki labels
442
     * @param array|string $logLine Log content
443
     * @param string|int|float|null $timestamp Timestamp - can be:
444
     *   - NULL: use current time
445
     *   - string: ISO format (2025-12-15 10:30:00) or MySQL datetime
446
     *   - int/float: Unix timestamp (seconds or with microseconds)
447
     */
448
    public function logWithTimestamp($labels, $logLine, $timestamp = NULL)
449
    {
450
        if (!$this->enabled) {
×
451
            return;
×
452
        }
453

454
        // Convert log line to JSON string if needed.
455
        if (is_array($logLine)) {
×
456
            $logLine = json_encode($logLine);
×
457
        }
458

459
        // Determine timestamp.
460
        if ($timestamp === NULL) {
×
461
            $ts = date('c');
×
462
        } elseif (is_string($timestamp)) {
×
463
            $unixTs = strtotime($timestamp);
×
464
            $ts = ($unixTs === FALSE) ? date('c') : date('c', $unixTs);
×
465
        } else {
466
            $ts = date('c', (int)$timestamp);
×
467
        }
468

469
        // Write directly to JSON file.
470
        $this->writeLogEntry($labels, $logLine, $ts);
×
471
    }
472

473
    /**
474
     * Write a single log entry to JSON file.
475
     *
476
     * @param array $labels Loki labels
477
     * @param string $logLine JSON-encoded log content
478
     * @param string $timestamp ISO format timestamp
479
     */
480
    private function writeLogEntry($labels, $logLine, $timestamp)
481
    {
482
        // Determine source from labels for filename.
483
        $source = $labels['source'] ?? 'api';
×
484
        $logFile = $this->jsonLogPath . '/' . $source . '.log';
×
485

486
        // Ensure directory exists.
487
        $dir = dirname($logFile);
×
488
        if (!is_dir($dir)) {
×
489
            @mkdir($dir, 0755, TRUE);
×
490
        }
491

492
        // Build entry structure matching what Alloy expects.
493
        $entry = [
×
494
            'timestamp' => $timestamp,
×
495
            'labels' => $labels,
×
496
            'message' => json_decode($logLine, TRUE) ?? $logLine,
×
497
        ];
×
498

499
        @file_put_contents($logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
×
500
    }
501

502
    /**
503
     * Flush is now a no-op since we write directly to files.
504
     * Kept for API compatibility.
505
     */
506
    public function flush()
507
    {
508
        // No-op - we write directly to files now.
509
    }
×
510

511
    /**
512
     * Push log entries directly to Loki HTTP API.
513
     * Used for historical backfill where we want immediate ingestion with specific timestamps.
514
     *
515
     * @param string $lokiUrl Loki push endpoint (e.g. http://loki:3100/loki/api/v1/push)
516
     * @param array $entries Array of entries, each with 'labels', 'logLine', 'timestamp' keys
517
     * @return bool TRUE on success, FALSE on failure
518
     */
519
    public function pushDirectToLoki($lokiUrl, $entries)
520
    {
521
        if (empty($entries)) {
×
522
            return TRUE;
×
523
        }
524

525
        // Group entries by label set (Loki requires same labels in a stream).
526
        $streams = [];
×
527

528
        foreach ($entries as $entry) {
×
529
            $labels = $entry['labels'] ?? [];
×
530
            $logLine = $entry['logLine'];
×
531
            $timestamp = $entry['timestamp'];
×
532

533
            // Convert log line to JSON string if needed.
534
            if (is_array($logLine)) {
×
535
                $logLine = json_encode($logLine);
×
536
            }
537

538
            // Convert timestamp to nanoseconds.
539
            if (is_string($timestamp)) {
×
540
                $unixTs = strtotime($timestamp);
×
541
                if ($unixTs === FALSE) {
×
542
                    $unixTs = time();
×
543
                }
544
            } else {
545
                $unixTs = (int)$timestamp;
×
546
            }
547
            $nanoTs = (string)($unixTs * 1000000000);
×
548

549
            // Create label key for grouping.
550
            ksort($labels);
×
551
            $labelKey = json_encode($labels);
×
552

553
            if (!isset($streams[$labelKey])) {
×
554
                $streams[$labelKey] = [
×
555
                    'stream' => $labels,
×
556
                    'values' => [],
×
557
                ];
×
558
            }
559

560
            $streams[$labelKey]['values'][] = [$nanoTs, $logLine];
×
561
        }
562

563
        // Build Loki push payload.
564
        $payload = [
×
565
            'streams' => array_values($streams),
×
566
        ];
×
567

568
        // Send to Loki.
569
        $ch = curl_init($lokiUrl);
×
570
        curl_setopt_array($ch, [
×
571
            CURLOPT_POST => TRUE,
×
572
            CURLOPT_POSTFIELDS => json_encode($payload),
×
573
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
×
574
            CURLOPT_RETURNTRANSFER => TRUE,
×
575
            CURLOPT_TIMEOUT => 30,
×
576
        ]);
×
577

578
        $response = curl_exec($ch);
×
579
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
580
        $error = curl_error($ch);
×
581
        curl_close($ch);
×
582

583
        if ($error) {
×
584
            error_log("Loki push error: $error");
×
585
            return FALSE;
×
586
        }
587

588
        if ($httpCode !== 204 && $httpCode !== 200) {
×
589
            error_log("Loki push failed with HTTP $httpCode: $response");
×
590
            return FALSE;
×
591
        }
592

593
        return TRUE;
×
594
    }
595

596
    /**
597
     * Hash email for privacy in logs.
598
     */
599
    private function hashEmail($email)
600
    {
601
        // Keep domain but hash local part for privacy.
602
        $parts = explode('@', $email);
×
603
        if (count($parts) === 2) {
×
604
            return substr(md5($parts[0]), 0, 8) . '@' . $parts[1];
×
605
        }
606
        return substr(md5($email), 0, 16);
×
607
    }
608

609
    /**
610
     * Destructor - no-op now since we write directly.
611
     */
612
    public function __destruct()
613
    {
614
        // No-op.
615
    }
×
616
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc