Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SendgridLogger
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 6
272
0.00% covered (danger)
0.00%
0 / 1
 log
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 logException
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 detectCaller
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 serializeMail
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 serializeResponse
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 reportLoggerFailure
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace App\Services;
4
5use App\Models\TblSendgridOutboundLog;
6use Illuminate\Support\Facades\Log;
7use SendGrid\Mail\Mail;
8use SendGrid\Response;
9use Throwable;
10
11/**
12 * Centralized SendGrid send logger. Writes one row per send attempt to
13 * tbl_sendgrid_outbound_log with the full request payload, the full
14 * response, and the calling Titan function.
15 *
16 * Best-effort: any failure inside this class is swallowed and written to
17 * the email_log file channel. Logging must NEVER prevent an email from
18 * being sent.
19 *
20 * Typical usage at each send site:
21 *
22 *     $response = $sendgrid->send($email);
23 *     SendgridLogger::log($email, $response);   // function_name auto-detected
24 *
25 * Or with explicit override (when one method has multiple internal send paths):
26 *
27 *     SendgridLogger::log($email, $response, 'Quotations::send_email_to_client (backup mailer)');
28 *
29 * For sends that threw before reaching SendGrid:
30 *
31 *     try {
32 *         $response = $sendgrid->send($email);
33 *         SendgridLogger::log($email, $response);
34 *     } catch (\Throwable $e) {
35 *         SendgridLogger::logException($email, $e);
36 *         throw $e;
37 *     }
38 */
39class SendgridLogger
40{
41    /**
42     * Log a SendGrid send that returned a response (success or HTTP error).
43     */
44    public static function log(Mail $email, Response $response, ?string $functionName = null): void
45    {
46        try {
47            $requestJson = self::serializeMail($email);
48            $responseJson = self::serializeResponse($response);
49
50            TblSendgridOutboundLog::create([
51                'function_name' => $functionName ?? self::detectCaller(),
52                'x_message_id' => $responseJson['headers']['x-message-id'] ?? null,
53                'request_json' => $requestJson,
54                'response_json' => $responseJson,
55            ]);
56        } catch (Throwable $e) {
57            self::reportLoggerFailure($e, $functionName);
58        }
59    }
60
61    /**
62     * Log a SendGrid send that threw before SendGrid replied (network error,
63     * SDK exception, etc). Stores the exception under response_json.exception
64     * so the row shape stays uniform.
65     */
66    public static function logException(Mail $email, Throwable $exception, ?string $functionName = null): void
67    {
68        try {
69            TblSendgridOutboundLog::create([
70                'function_name' => $functionName ?? self::detectCaller(),
71                'x_message_id' => null,
72                'request_json' => self::serializeMail($email),
73                'response_json' => [
74                    'exception' => $exception::class.': '.$exception->getMessage(),
75                ],
76            ]);
77        } catch (Throwable $e) {
78            self::reportLoggerFailure($e, $functionName);
79        }
80    }
81
82    /**
83     * Walk debug_backtrace to find the function that called log() or
84     * logException(). Returns 'Class::method' or just 'method' for callers
85     * outside a class. Defaults to 'unknown' if the stack is unreadable
86     * (e.g. heavily optimized opcache + JIT).
87     */
88    private static function detectCaller(): string
89    {
90        // Limit depth to 4: [0] = detectCaller, [1] = log/logException,
91        // [2] = the caller (what we want), [3] = its caller (slack).
92        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
93        $frame = $trace[2] ?? null;
94
95        if ($frame === null) {
96            return 'unknown';
97        }
98
99        $function = $frame['function'] ?? 'unknown';
100        $class = $frame['class'] ?? '';
101
102        if ($class === '') {
103            return $function;
104        }
105
106        // Use the short class name; full namespace adds noise without
107        // value for the dashboards that filter by function_name.
108        $short = (string) (strrchr($class, '\\') ?: '\\'.$class);
109        $short = ltrim($short, '\\');
110
111        return $short.'::'.$function;
112    }
113
114    /**
115     * Convert SendGrid's Mail object to a plain array. The SDK already
116     * implements JsonSerializable returning the exact payload it'll POST
117     * to SendGrid's API, so this is identical to what SendGrid receives.
118     */
119    private static function serializeMail(Mail $email): array
120    {
121        $serialized = $email->jsonSerialize();
122
123        // jsonSerialize() returns an object with public properties; encode
124        // → decode normalizes nested SendGrid SDK objects to plain arrays
125        // so the MySQL JSON column stores them cleanly.
126        $json = json_encode($serialized);
127
128        return $json === false ? [] : (array) json_decode($json, true);
129    }
130
131    /**
132     * Bundle the SendGrid Response into a uniform array shape:
133     *   { status_code: int, headers: {lowercased: value}, body: string }
134     */
135    private static function serializeResponse(Response $response): array
136    {
137        $headers = [];
138        foreach ((array) $response->headers() as $line) {
139            $line = (string) $line;
140            if (! str_contains($line, ':')) {
141                continue;
142            }
143            [$k, $v] = array_pad(explode(':', $line, 2), 2, '');
144            $key = strtolower(trim($k));
145            if ($key === '') {
146                continue;
147            }
148            $headers[$key] = trim($v);
149        }
150
151        return [
152            'status_code' => $response->statusCode(),
153            'headers' => $headers,
154            'body' => (string) $response->body(),
155        ];
156    }
157
158    private static function reportLoggerFailure(Throwable $e, ?string $functionName): void
159    {
160        // Last-resort: file log. Must not throw — wrap in try/catch since
161        // the log channel itself can fail (storage/logs not writable, etc).
162        try {
163            Log::channel('email_log')->warning('SendgridLogger persist failed: '.$e->getMessage(), [
164                'function_name' => $functionName ?? '(auto-detect-failed)',
165                'exception' => $e::class,
166            ]);
167        } catch (Throwable) {
168            // Truly cannot log — give up silently. We will NOT let logging
169            // failures take down email sending.
170        }
171    }
172}