UNPKG

6.91 kBJavaScriptView Raw
1import { pino } from "pino";
2import { environment, isProduction } from "./env.js";
3import { AppError } from "./error.js";
4import { isNil, isPlainObject, merge } from "./lodash.js";
5import { loggerWriteGithubActions, loggerWritePretty } from "./log-writers.js";
6import { _compasSentryExport } from "./sentry.js";
7
8/**
9 * @typedef {object} Logger
10 *
11 * The logger only has two severities:
12 * - info
13 * - error
14 *
15 * Either a log line is innocent enough and only provides debug information if needed, or
16 * someone should be paged because something goes wrong. For example, handled 400 errors
17 * probably do not require anyones attention, but unhandled 500 errors do.
18 *
19 * The log functions only accept a single parameter. This prevents magic
20 * outputs like automatic concatenating strings in to a single message, or always having
21 * a top-level array as a message.
22 * @property {function(*): void} info
23 * @property {function(*): void} error
24 */
25
26/**
27 * @type {Record<string, any>}
28 */
29const globalContext = {};
30
31/**
32 * @type {import("pino").DestinationStream|undefined}
33 */
34let globalDestination = undefined;
35
36/**
37 * @type {import("pino").Logger}
38 */
39let rootInstance = loggerBuildRootInstance(pino.destination(1));
40
41/**
42 * Deeply update the global logger context. This only affects newly created loggers
43 *
44 * @param {Record<string, any>} context
45 */
46export function loggerExtendGlobalContext(context) {
47 if (!isPlainObject(context)) {
48 throw AppError.serverError({
49 message: "First argument should be a plain JS object",
50 });
51 }
52
53 merge(globalContext, context);
54}
55
56/**
57 * Set the global logger destination to use the provided pino destination. This only
58 * affects loggers created after this function call.
59 *
60 * You can use any form of support Pino destination here. Even
61 * {@link https://getpino.io/#/docs/transports Pino transports} via `pino.transport()`.
62 * It defaults to `pino.destination(1)` which writes to `process.stdout`.
63 *
64 * @see https://getpino.io/#/docs/api?id=pino-destination
65 *
66 * @param {import("pino").DestinationStream} destination
67 */
68export function loggerSetGlobalDestination(destination) {
69 globalDestination = destination;
70 rootInstance = loggerBuildRootInstance(destination);
71}
72
73/**
74 * Returns the current pino destination.
75 *
76 * This can be used to temporarily set a different destination for the newly created
77 * loggers and then reusing the destination for the rest of your application.
78 *
79 * @returns {import("pino").DestinationStream}
80 */
81export function loggerGetGlobalDestination() {
82 // @ts-expect-error
83 return globalDestination;
84}
85
86/**
87 * Set the global root pino instance. We use a single instance, so the same destination
88 * will be used for all sub loggers.
89 *
90 * @param {import("pino").DestinationStream} destination
91 */
92export function loggerBuildRootInstance(destination) {
93 return pino(
94 {
95 formatters: {
96 level: (label) => ({ level: label }),
97 bindings: () => ({}),
98 },
99 serializers: {},
100 base: {},
101 },
102 destination,
103 );
104}
105
106/**
107 * Create a new logger instance. The provided `ctx` will shallowly overwrite the global
108 * context that is set via {@see loggerExtendGlobalContext}.
109 *
110 * The logger uses the transport or destination set via
111 *
112 * @param {{ ctx?: Record<string, any> }} [options]
113 * @returns {Logger}
114 */
115export function newLogger(options) {
116 const context = options?.ctx
117 ? {
118 ...globalContext,
119 ...options.ctx,
120 }
121 : globalContext;
122
123 const childLogger = rootInstance.child({
124 context,
125 });
126
127 if (typeof _compasSentryExport?.addBreadcrumb === "function") {
128 let addedContextAsBreadcrumb = false;
129
130 return {
131 info: (message) => {
132 if (!addedContextAsBreadcrumb) {
133 // @ts-expect-error
134 _compasSentryExport.addBreadcrumb({
135 category: context.type,
136 data: {
137 ...context,
138 },
139 level: "info",
140 type: "default",
141 });
142 addedContextAsBreadcrumb = true;
143 }
144
145 // @ts-expect-error
146 _compasSentryExport.addBreadcrumb({
147 category: context.type,
148 data: typeof message === "string" ? undefined : message,
149 message: typeof message === "string" ? message : undefined,
150 level: "info",
151 type: "default",
152 });
153
154 childLogger.info({ message });
155 },
156 error: (message) => {
157 if (!addedContextAsBreadcrumb) {
158 // @ts-expect-error
159 _compasSentryExport.addBreadcrumb({
160 category: "log",
161 data: {
162 ...context,
163 },
164 level: "info",
165 type: "default",
166 });
167 addedContextAsBreadcrumb = true;
168 }
169
170 // @ts-expect-error
171 _compasSentryExport.addBreadcrumb({
172 category: "log",
173 data: typeof message === "string" ? undefined : message,
174 message: typeof message === "string" ? message : undefined,
175 level: "error",
176 type: "error",
177 });
178
179 childLogger.error({ message });
180 },
181 };
182 }
183
184 return {
185 info: (message) => childLogger.info({ message }),
186 error: (message) => childLogger.error({ message }),
187 };
188}
189
190/**
191 * Infer the default printer that we should use based on the currently set environment
192 * variables.
193 *
194 * Can be overwritten by `process.env.COMPAS_LOG_PRINTER`. Accepted values are `pretty',
195 * 'ndjson', 'github-actions'.
196 */
197export function loggerDetermineDefaultDestination() {
198 if (globalDestination) {
199 return;
200 }
201
202 const isProd = isProduction();
203 const isGitHubActions = environment.GITHUB_ACTIONS === "true";
204 let printer = environment.COMPAS_LOG_PRINTER;
205
206 if (isNil(printer)) {
207 printer = isGitHubActions ? "github-actions" : isProd ? "ndjson" : "pretty";
208 }
209
210 if (!["github-actions", "ndjson", "pretty"].includes(printer)) {
211 throw AppError.serverError({
212 message: `process.env.COMPAS_LOG_PRINTER is set to a '${printer}', but only accepts 'ndjson', 'pretty' or 'github-actions'.`,
213 });
214 }
215
216 if (printer === "ndjson") {
217 return;
218 }
219
220 loggerSetGlobalDestination(
221 loggerGetPrettyPrinter({
222 addGitHubActionsAnnotations: printer === "github-actions",
223 }),
224 );
225}
226
227/**
228 *
229 * @param {{
230 * addGitHubActionsAnnotations: boolean,
231 * }} options
232 * @returns {import("pino").DestinationStream}
233 */
234export function loggerGetPrettyPrinter(options) {
235 function write(line) {
236 const data = JSON.parse(line);
237
238 if (options.addGitHubActionsAnnotations) {
239 loggerWriteGithubActions(
240 process.stdout,
241 data.level,
242 new Date(data.time),
243 data.context,
244 data.message,
245 );
246 } else {
247 loggerWritePretty(
248 process.stdout,
249 data.level,
250 new Date(data.time),
251 data.context,
252 data.message,
253 );
254 }
255 }
256
257 return {
258 write,
259 };
260}