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

Freegle / iznik-server / #2551

19 Dec 2025 10:01AM UTC coverage: 89.472% (-0.06%) from 89.531%
#2551

push

php-coveralls

edwh
Simplify AI summary prompt text

Remove comparison to raw git commits in the AI-generated summary header.

26592 of 29721 relevant lines covered (89.47%)

31.74 hits per line

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

4.0
/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
    ];
81

82
    /**
83
     * Get trace headers from the current request.
84
     *
85
     * @return array Trace information from headers
86
     */
87
    public function getTraceHeaders()
88
    {
89
        $headers = [];
×
90

91
        // Get trace headers from Apache headers or $_SERVER.
92
        if (function_exists('apache_request_headers')) {
×
93
            $requestHeaders = apache_request_headers();
×
94
            foreach ($requestHeaders as $name => $value) {
×
95
                $nameLower = strtolower($name);
×
96
                if ($nameLower === 'x-trace-id') {
×
97
                    $headers['trace_id'] = $value;
×
98
                } elseif ($nameLower === 'x-session-id') {
×
99
                    $headers['session_id'] = $value;
×
100
                } elseif ($nameLower === 'x-client-timestamp') {
×
101
                    $headers['client_timestamp'] = $value;
×
102
                }
103
            }
104
        }
105

106
        // Fallback to $_SERVER.
107
        if (empty($headers['trace_id']) && !empty($_SERVER['HTTP_X_TRACE_ID'])) {
×
108
            $headers['trace_id'] = $_SERVER['HTTP_X_TRACE_ID'];
×
109
        }
110
        if (empty($headers['session_id']) && !empty($_SERVER['HTTP_X_SESSION_ID'])) {
×
111
            $headers['session_id'] = $_SERVER['HTTP_X_SESSION_ID'];
×
112
        }
113
        if (empty($headers['client_timestamp']) && !empty($_SERVER['HTTP_X_CLIENT_TIMESTAMP'])) {
×
114
            $headers['client_timestamp'] = $_SERVER['HTTP_X_CLIENT_TIMESTAMP'];
×
115
        }
116

117
        return $headers;
×
118
    }
119

120
    /**
121
     * Maximum string length for logged values.
122
     */
123
    const MAX_STRING_LENGTH = 32;
124

125
    /**
126
     * Truncate a string to MAX_STRING_LENGTH characters.
127
     *
128
     * @param string $value String to truncate
129
     * @return string Truncated string
130
     */
131
    private function truncateString($value)
132
    {
133
        if (strlen($value) <= self::MAX_STRING_LENGTH) {
×
134
            return $value;
×
135
        }
136
        return substr($value, 0, self::MAX_STRING_LENGTH) . '...';
×
137
    }
138

139
    /**
140
     * Recursively truncate all string values in an array.
141
     *
142
     * @param mixed $data Data to truncate
143
     * @return mixed Truncated data
144
     */
145
    private function truncateData($data)
146
    {
147
        if (is_string($data)) {
×
148
            return $this->truncateString($data);
×
149
        }
150

151
        if (is_array($data)) {
×
152
            $result = [];
×
153
            foreach ($data as $key => $value) {
×
154
                $result[$key] = $this->truncateData($value);
×
155
            }
156
            return $result;
×
157
        }
158

159
        return $data;
×
160
    }
161

162
    /**
163
     * Log API request to Loki.
164
     *
165
     * @param string $version API version (v1 or v2)
166
     * @param string $method HTTP method
167
     * @param string $endpoint API endpoint
168
     * @param int $statusCode HTTP status code
169
     * @param float $duration Request duration in milliseconds
170
     * @param int|null $userId User ID if authenticated
171
     * @param array $extra Extra fields to log
172
     */
173
    public function logApiRequest($version, $method, $endpoint, $statusCode, $duration, $userId = NULL, $extra = [])
174
    {
175
        if (!$this->enabled) {
×
176
            return;
×
177
        }
178

179
        $labels = [
×
180
            'app' => 'freegle',
×
181
            'source' => 'api',
×
182
            'api_version' => $version,
×
183
            'method' => $method,
×
184
            'status_code' => (string)$statusCode,
×
185
        ];
×
186

187
        // Include trace headers for distributed tracing correlation.
188
        $traceHeaders = $this->getTraceHeaders();
×
189

190
        $logLine = array_merge([
×
191
            'endpoint' => $endpoint,
×
192
            'duration_ms' => $duration,
×
193
            'user_id' => $userId,
×
194
            'timestamp' => date('c'),
×
195
        ], $traceHeaders, $extra);
×
196

197
        $this->log($labels, $logLine);
×
198
    }
199

200
    /**
201
     * Log API request with full request/response data to Loki.
202
     *
203
     * @param string $version API version (v1 or v2)
204
     * @param string $method HTTP method
205
     * @param string $endpoint API endpoint
206
     * @param int $statusCode HTTP status code
207
     * @param float $duration Request duration in milliseconds
208
     * @param int|null $userId User ID if authenticated
209
     * @param array $extra Extra fields to log
210
     * @param array $queryParams Query parameters (will be truncated)
211
     * @param array|null $requestBody Request body (will be truncated)
212
     * @param array|null $responseBody Response body (will be truncated)
213
     */
214
    public function logApiRequestFull($version, $method, $endpoint, $statusCode, $duration, $userId = NULL, $extra = [], $queryParams = [], $requestBody = NULL, $responseBody = NULL)
215
    {
216
        if (!$this->enabled) {
×
217
            return;
×
218
        }
219

220
        $labels = [
×
221
            'app' => 'freegle',
×
222
            'source' => 'api',
×
223
            'api_version' => $version,
×
224
            'method' => $method,
×
225
            'status_code' => (string)$statusCode,
×
226
        ];
×
227

228
        // Include trace headers for distributed tracing correlation.
229
        $traceHeaders = $this->getTraceHeaders();
×
230

231
        $logLine = array_merge([
×
232
            'endpoint' => $endpoint,
×
233
            'duration_ms' => $duration,
×
234
            'user_id' => $userId,
×
235
            'timestamp' => date('c'),
×
236
        ], $traceHeaders, $extra);
×
237

238
        // Add query parameters (truncated).
239
        if (!empty($queryParams)) {
×
240
            $logLine['query_params'] = $this->truncateData($queryParams);
×
241
        }
242

243
        // Add request body (truncated).
244
        if (!empty($requestBody)) {
×
245
            $logLine['request_body'] = $this->truncateData($requestBody);
×
246
        }
247

248
        // Add response body (truncated).
249
        if (!empty($responseBody)) {
×
250
            $logLine['response_body'] = $this->truncateData($responseBody);
×
251
        }
252

253
        $this->log($labels, $logLine);
×
254
    }
255

256
    /**
257
     * Log API headers to Loki (separate stream with longer retention for debugging).
258
     *
259
     * @param string $version API version (v1 or v2)
260
     * @param string $method HTTP method
261
     * @param string $endpoint API endpoint
262
     * @param array $requestHeaders Request headers
263
     * @param array $responseHeaders Response headers
264
     * @param int|null $userId User ID if authenticated
265
     * @param string|null $requestId Unique request ID for correlation with main API log
266
     */
267
    public function logApiHeaders($version, $method, $endpoint, $requestHeaders, $responseHeaders, $userId = NULL, $requestId = NULL)
268
    {
269
        if (!$this->enabled) {
×
270
            return;
×
271
        }
272

273
        $labels = [
×
274
            'app' => 'freegle',
×
275
            'source' => 'api_headers',
×
276
            'api_version' => $version,
×
277
            'method' => $method,
×
278
        ];
×
279

280
        $logLine = [
×
281
            'endpoint' => $endpoint,
×
282
            'user_id' => $userId,
×
283
            'request_id' => $requestId,
×
284
            'request_headers' => $this->filterHeaders($requestHeaders, TRUE),
×
285
            'response_headers' => $this->filterHeaders($responseHeaders, FALSE),
×
286
            'timestamp' => date('c'),
×
287
        ];
×
288

289
        $this->log($labels, $logLine);
×
290
    }
291

292
    /**
293
     * Filter headers to remove sensitive information.
294
     *
295
     * @param array $headers Headers to filter
296
     * @param bool $useAllowlist Whether to use allowlist (for request headers) or just blocklist
297
     * @return array Filtered headers
298
     */
299
    private function filterHeaders($headers, $useAllowlist = FALSE)
300
    {
301
        $filtered = [];
×
302

303
        foreach ($headers as $name => $value) {
×
304
            $nameLower = strtolower($name);
×
305

306
            // Check against sensitive patterns.
307
            $isSensitive = FALSE;
×
308
            foreach (self::$sensitiveHeaderPatterns as $pattern) {
×
309
                if (preg_match($pattern, $name)) {
×
310
                    $isSensitive = TRUE;
×
311
                    break;
×
312
                }
313
            }
314

315
            if ($isSensitive) {
×
316
                continue;
×
317
            }
318

319
            // For request headers, use allowlist.
320
            if ($useAllowlist) {
×
321
                if (in_array($nameLower, self::$allowedRequestHeaders)) {
×
322
                    $filtered[$name] = is_array($value) ? implode(', ', $value) : $value;
×
323
                }
324
            } else {
325
                // For response headers, include all non-sensitive.
326
                $filtered[$name] = is_array($value) ? implode(', ', $value) : $value;
×
327
            }
328
        }
329

330
        return $filtered;
×
331
    }
332

333
    /**
334
     * Log email send to Loki.
335
     *
336
     * @param string $type Email type (digest, notification, etc.)
337
     * @param string $recipient Recipient email address
338
     * @param string $subject Email subject
339
     * @param int|null $userId User ID
340
     * @param int|null $groupId Group ID
341
     * @param array $extra Extra fields to log
342
     */
343
    public function logEmailSend($type, $recipient, $subject, $userId = NULL, $groupId = NULL, $extra = [])
344
    {
345
        if (!$this->enabled) {
×
346
            return;
×
347
        }
348

349
        $labels = [
×
350
            'app' => 'freegle',
×
351
            'source' => 'email',
×
352
            'email_type' => $type,
×
353
        ];
×
354

355
        if ($groupId) {
×
356
            $labels['groupid'] = (string)$groupId;
×
357
        }
358

359
        $logLine = array_merge([
×
360
            'recipient' => $this->hashEmail($recipient),
×
361
            'subject' => $subject,
×
362
            'user_id' => $userId,
×
363
            'group_id' => $groupId,
×
364
            'timestamp' => date('c'),
×
365
        ], $extra);
×
366

367
        $this->log($labels, $logLine);
×
368
    }
369

370
    /**
371
     * Log from the logs table (for dual-write).
372
     *
373
     * @param array $params Log parameters matching logs table columns
374
     */
375
    public function logFromLogsTable($params)
376
    {
377
        if (!$this->enabled) {
×
378
            return;
×
379
        }
380

381
        $labels = [
×
382
            'app' => 'freegle',
×
383
            'source' => 'logs_table',
×
384
            'type' => $params['type'] ?? 'unknown',
×
385
            'subtype' => $params['subtype'] ?? 'unknown',
×
386
        ];
×
387

388
        if (!empty($params['groupid'])) {
×
389
            $labels['groupid'] = (string)$params['groupid'];
×
390
        }
391

392
        $logLine = [
×
393
            'user' => $params['user'] ?? NULL,
×
394
            'byuser' => $params['byuser'] ?? NULL,
×
395
            'msgid' => $params['msgid'] ?? NULL,
×
396
            'groupid' => $params['groupid'] ?? NULL,
×
397
            'text' => $params['text'] ?? NULL,
×
398
            'configid' => $params['configid'] ?? NULL,
×
399
            'stdmsgid' => $params['stdmsgid'] ?? NULL,
×
400
            'bulkopid' => $params['bulkopid'] ?? NULL,
×
401
            'timestamp' => $params['timestamp'] ?? date('c'),
×
402
        ];
×
403

404
        $this->log($labels, $logLine);
×
405
    }
406

407
    /**
408
     * Send a log entry to Loki.
409
     *
410
     * @param array $labels Loki labels
411
     * @param array|string $logLine Log content
412
     */
413
    public function log($labels, $logLine)
414
    {
415
        $this->logWithTimestamp($labels, $logLine, NULL);
×
416
    }
417

418
    /**
419
     * Send a log entry to Loki with a specific timestamp (for historical backfill).
420
     *
421
     * @param array $labels Loki labels
422
     * @param array|string $logLine Log content
423
     * @param string|int|float|null $timestamp Timestamp - can be:
424
     *   - NULL: use current time
425
     *   - string: ISO format (2025-12-15 10:30:00) or MySQL datetime
426
     *   - int/float: Unix timestamp (seconds or with microseconds)
427
     */
428
    public function logWithTimestamp($labels, $logLine, $timestamp = NULL)
429
    {
430
        if (!$this->enabled) {
×
431
            return;
×
432
        }
433

434
        // Convert log line to JSON string if needed.
435
        if (is_array($logLine)) {
×
436
            $logLine = json_encode($logLine);
×
437
        }
438

439
        // Determine timestamp.
440
        if ($timestamp === NULL) {
×
441
            $ts = date('c');
×
442
        } elseif (is_string($timestamp)) {
×
443
            $unixTs = strtotime($timestamp);
×
444
            $ts = ($unixTs === FALSE) ? date('c') : date('c', $unixTs);
×
445
        } else {
446
            $ts = date('c', (int)$timestamp);
×
447
        }
448

449
        // Write directly to JSON file.
450
        $this->writeLogEntry($labels, $logLine, $ts);
×
451
    }
452

453
    /**
454
     * Write a single log entry to JSON file.
455
     *
456
     * @param array $labels Loki labels
457
     * @param string $logLine JSON-encoded log content
458
     * @param string $timestamp ISO format timestamp
459
     */
460
    private function writeLogEntry($labels, $logLine, $timestamp)
461
    {
462
        // Determine source from labels for filename.
463
        $source = $labels['source'] ?? 'api';
×
464
        $logFile = $this->jsonLogPath . '/' . $source . '.log';
×
465

466
        // Ensure directory exists.
467
        $dir = dirname($logFile);
×
468
        if (!is_dir($dir)) {
×
469
            @mkdir($dir, 0755, TRUE);
×
470
        }
471

472
        // Build entry structure matching what Alloy expects.
473
        $entry = [
×
474
            'timestamp' => $timestamp,
×
475
            'labels' => $labels,
×
476
            'message' => json_decode($logLine, TRUE) ?? $logLine,
×
477
        ];
×
478

479
        @file_put_contents($logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
×
480
    }
481

482
    /**
483
     * Flush is now a no-op since we write directly to files.
484
     * Kept for API compatibility.
485
     */
486
    public function flush()
487
    {
488
        // No-op - we write directly to files now.
489
    }
×
490

491
    /**
492
     * Push log entries directly to Loki HTTP API.
493
     * Used for historical backfill where we want immediate ingestion with specific timestamps.
494
     *
495
     * @param string $lokiUrl Loki push endpoint (e.g. http://loki:3100/loki/api/v1/push)
496
     * @param array $entries Array of entries, each with 'labels', 'logLine', 'timestamp' keys
497
     * @return bool TRUE on success, FALSE on failure
498
     */
499
    public function pushDirectToLoki($lokiUrl, $entries)
500
    {
501
        if (empty($entries)) {
×
502
            return TRUE;
×
503
        }
504

505
        // Group entries by label set (Loki requires same labels in a stream).
506
        $streams = [];
×
507

508
        foreach ($entries as $entry) {
×
509
            $labels = $entry['labels'] ?? [];
×
510
            $logLine = $entry['logLine'];
×
511
            $timestamp = $entry['timestamp'];
×
512

513
            // Convert log line to JSON string if needed.
514
            if (is_array($logLine)) {
×
515
                $logLine = json_encode($logLine);
×
516
            }
517

518
            // Convert timestamp to nanoseconds.
519
            if (is_string($timestamp)) {
×
520
                $unixTs = strtotime($timestamp);
×
521
                if ($unixTs === FALSE) {
×
522
                    $unixTs = time();
×
523
                }
524
            } else {
525
                $unixTs = (int)$timestamp;
×
526
            }
527
            $nanoTs = (string)($unixTs * 1000000000);
×
528

529
            // Create label key for grouping.
530
            ksort($labels);
×
531
            $labelKey = json_encode($labels);
×
532

533
            if (!isset($streams[$labelKey])) {
×
534
                $streams[$labelKey] = [
×
535
                    'stream' => $labels,
×
536
                    'values' => [],
×
537
                ];
×
538
            }
539

540
            $streams[$labelKey]['values'][] = [$nanoTs, $logLine];
×
541
        }
542

543
        // Build Loki push payload.
544
        $payload = [
×
545
            'streams' => array_values($streams),
×
546
        ];
×
547

548
        // Send to Loki.
549
        $ch = curl_init($lokiUrl);
×
550
        curl_setopt_array($ch, [
×
551
            CURLOPT_POST => TRUE,
×
552
            CURLOPT_POSTFIELDS => json_encode($payload),
×
553
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
×
554
            CURLOPT_RETURNTRANSFER => TRUE,
×
555
            CURLOPT_TIMEOUT => 30,
×
556
        ]);
×
557

558
        $response = curl_exec($ch);
×
559
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
560
        $error = curl_error($ch);
×
561
        curl_close($ch);
×
562

563
        if ($error) {
×
564
            error_log("Loki push error: $error");
×
565
            return FALSE;
×
566
        }
567

568
        if ($httpCode !== 204 && $httpCode !== 200) {
×
569
            error_log("Loki push failed with HTTP $httpCode: $response");
×
570
            return FALSE;
×
571
        }
572

573
        return TRUE;
×
574
    }
575

576
    /**
577
     * Hash email for privacy in logs.
578
     */
579
    private function hashEmail($email)
580
    {
581
        // Keep domain but hash local part for privacy.
582
        $parts = explode('@', $email);
×
583
        if (count($parts) === 2) {
×
584
            return substr(md5($parts[0]), 0, 8) . '@' . $parts[1];
×
585
        }
586
        return substr(md5($email), 0, 16);
×
587
    }
588

589
    /**
590
     * Destructor - no-op now since we write directly.
591
     */
592
    public function __destruct()
593
    {
594
        // No-op.
595
    }
×
596
}
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