UNPKG

@google-cloud/pino-logging-gcp-config

Version:

Module to create a basic Pino LoggerConfig to support Google Cloud structured logging

297 lines 14.3 kB
"use strict"; /** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); // eslint-disable-next-line n/no-unpublished-import require("jasmine"); const pino_gcp_config_1 = require("./pino_gcp_config"); const pino = __importStar(require("pino")); const instrumentation_1 = require("@google-cloud/logging/build/src/utils/instrumentation"); const fs = __importStar(require("node:fs/promises")); const os = __importStar(require("node:os")); const crypto = __importStar(require("node:crypto")); const serviceContext = { service: 'test', version: '0.1.0', }; describe('Pino config', () => { describe('Without project ID', () => { const loggerConfig = { serviceContext, // Blank project ID prevents env lookup, but means no project ID set. traceGoogleCloudProjectId: '', inihibitDiagnosticMessage: true, }; const config = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)(loggerConfig); it('should add mixins to the config object', () => { const pinoLoggerOptions = { level: 'defaultLevel', formatters: { bindings: x => { return { a: x }; }, }, }; const configWithMixins = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)(loggerConfig, pinoLoggerOptions); expect(configWithMixins.level).toEqual('defaultLevel'); expect(configWithMixins.formatters?.bindings).toBeDefined(); }); describe('Log level conversion', () => { // Need to define the return type of levelConverter because pino leaves it // as a plain `object` // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain const levelConverter = config.formatters?.level; Object.entries(pino_gcp_config_1.TEST_ONLY.PINO_TO_GCP_LOG_LEVELS).forEach(e => { it(`converts pino level ${e[0]} to GCP ${e[1]}`, () => { const convertedLevel = levelConverter(e[0], 42); expect(convertedLevel.severity).toEqual(e[1]); }); }); it('defaults to INFO for an unknown level', () => { expect(levelConverter('blah', 66).severity).toEqual('INFO'); }); }); describe('log formatter', () => { // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain const formatLogObject = config.formatters?.log; const TEST_DATE = new Date('2024-04-13T16:12:34.123Z'); beforeEach(() => { jasmine.clock().install(); jasmine.clock().mockDate(TEST_DATE); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should add serviceContext', () => { const logObject = formatLogObject({}); expect(logObject.serviceContext).toEqual({ service: 'test', version: '0.1.0', }); }); it('should add insertId', () => { const logObject = formatLogObject({}); expect(logObject['logging.googleapis.com/insertId']).toBeDefined(); expect(logObject['logging.googleapis.com/insertId'].length).toBeGreaterThan(1); }); it('should add stack_trace when err is an Error', () => { const logObject = formatLogObject({ err: new Error('some error message'), message: 'thrown an exception', }); expect(logObject.stack_trace).toMatch('Error: some error message\n'); }); it('should not add stack_trace when err is not an Error', () => { const logObject = formatLogObject({ err: 'some error message', message: 'thrown a string as an exception', }); expect(logObject.stack_trace).toBeUndefined(); }); it('should not map span and trace properties when not present', () => { const formattedLog = formatLogObject({ message: 'hello' }); expect(formattedLog['logging.googleapis.com/trace_sampled']).toBeUndefined(); expect(formattedLog['logging.googleapis.com/trace']).toBeUndefined(); expect(formattedLog['logging.googleapis.com/spanId']).toBeUndefined(); }); it('should replace span and trace properties when present', () => { const formattedLog = formatLogObject({ message: 'hello', trace_id: 'trace:12345', span_id: 'span:23456', trace_flags: 1, }); expect(formattedLog['logging.googleapis.com/trace_sampled']).toBeTrue(); expect(formattedLog['logging.googleapis.com/trace']).toBe('trace:12345'); expect(formattedLog['logging.googleapis.com/spanId']).toBe('span:23456'); expect(formattedLog.trace_id).toBeUndefined(); expect(formattedLog.span_id).toBeUndefined(); expect(formattedLog.trace_flags).toBeUndefined(); }); it('should not mutate input', () => { const input = {}; formatLogObject(input); expect(input).toEqual({}); }); it('should call user-defined formatter.log before GCP formatting', () => { // userLog will add a custom property, which should be present in the final GCP-formatted log const userLog = jasmine.createSpy('userLog').and.callFake(entry => { return { ...entry, custom: 'user' }; }); const configWithUserLog = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)(loggerConfig, { formatters: { log: userLog, }, }); const logObj = { foo: 'bar' }; const result = configWithUserLog.formatters.log(logObj); expect(userLog).toHaveBeenCalled(); expect(result).toBeDefined(); expect(result.foo).toBe('bar'); expect(result.custom).toBe('user'); expect(result.serviceContext).toEqual(serviceContext); expect(result['logging.googleapis.com/insertId']).toBeDefined(); }); it('adds a timestamp as seconds:nanos JSON fragment', () => { const timestampGenerator = config.timestamp; expect(timestampGenerator()).toEqual(',"timestamp":{"seconds":1713024754,"nanos":123000000}'); }); it('adds a timestamp as seconds:nanos JSON fragment when nanos is 0', () => { jasmine.clock().mockDate(new Date('2024-04-13T16:12:34.000Z')); const timestampGenerator = config.timestamp; expect(timestampGenerator()).toEqual(',"timestamp":{"seconds":1713024754,"nanos":0}'); }); }); }); describe('with project ID', () => { const config = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)({ serviceContext, // Blank project ID prevents env lookup, but means no project ID set. traceGoogleCloudProjectId: 'test-project', inihibitDiagnosticMessage: true, }); it('should replace span and trace properties when present', () => { const formattedLog = config.formatters.log({ message: 'hello', trace_id: 'trace:12345', span_id: 'span:23456', trace_flags: 1, }); expect(formattedLog['logging.googleapis.com/trace_sampled']).toBeTrue(); expect(formattedLog['logging.googleapis.com/trace']).toBe('projects/test-project/traces/trace:12345'); expect(formattedLog['logging.googleapis.com/spanId']).toBe('span:23456'); expect(formattedLog.trace_id).toBeUndefined(); expect(formattedLog.span_id).toBeUndefined(); expect(formattedLog.trace_flags).toBeUndefined(); }); }); }); describe('Instrumentation', () => { let tempPath; // Initialise pinoDestinationStream with stdout to get the correct type // info. let pinoDestinationStream = pino.destination(1); beforeEach(() => { (0, instrumentation_1.setInstrumentationStatus)(false); // This resets a global property set by cloud logging which enforces // that instrumentation is only written once per process. However we want // to retest it multiple times in the same process... global.shouldSkipInstrumentationCheck = false; // Create tempfile and destinationStream. tempPath = os.tmpdir() + '/pino-test-' + crypto.randomBytes(16).toString('hex'); pinoDestinationStream = pino.destination({ dest: tempPath, sync: true }); }); afterEach(async () => { // Cleanup tempfile. pinoDestinationStream?.destroy(); await fs.rm(tempPath); }); it('Should log instrumentation to specified stream', async () => { const config = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)({ serviceContext, traceGoogleCloudProjectId: 'test-project', pinoDestinationStream: pinoDestinationStream, }); pino.pino(config, pinoDestinationStream).info('hello world'); pinoDestinationStream.flushSync(); const logged = (await fs.readFile(tempPath, 'utf8')) .trim() .split('\n') .map(s => JSON.parse(s)); expect(logged[0]['logging.googleapis.com/diagnostic']?.instrumentation_source[0] ?.name).toBe('nodejs-gcppino'); expect(logged[1]?.message).toBe('hello world'); }); it('Should not log any instrumentation', async () => { const config = (0, pino_gcp_config_1.createGcpLoggingPinoConfig)({ serviceContext, traceGoogleCloudProjectId: 'test-project', pinoDestinationStream: pinoDestinationStream, inihibitDiagnosticMessage: true, }); pino.pino(config, pinoDestinationStream).info('hello world'); pinoDestinationStream.flushSync(); const logged = await fs.readFile(tempPath, 'utf8'); expect(logged).not.toContain('logging.googleapis.com/diagnostic'); expect(logged).toContain('hello world'); }); it('Should include pinoOptionsMixins results into any instrumentation', async () => { (0, pino_gcp_config_1.createGcpLoggingPinoConfig)({ serviceContext, traceGoogleCloudProjectId: 'test-project', pinoDestinationStream: pinoDestinationStream, }, { name: 'CustomLoggerName', }); pinoDestinationStream.flushSync(); const logged = JSON.parse(await fs.readFile(tempPath, 'utf8')); expect(logged['logging.googleapis.com/diagnostic']?.instrumentation_source[0] ?.name).toBe('nodejs-gcppino'); expect(logged.name).toBe('CustomLoggerName'); }); it('Should log instrumentation asynchronously after construction', async () => { // Creating the logging without serviceContext or projectID to trigger // retrieving them async via the environment. // Use the TEST_ONLY constructor to access the pendingInit member const gcpLoggingPino = new pino_gcp_config_1.TEST_ONLY.GcpLoggingPino({ pinoDestinationStream: pinoDestinationStream, }); expect(gcpLoggingPino.pendingInit).toBeInstanceOf(Promise); await gcpLoggingPino.pendingInit; pino .pino(gcpLoggingPino.pinoLoggerOptions, pinoDestinationStream) .info('hello world'); pinoDestinationStream.flushSync(); const logged = await fs.readFile(tempPath, 'utf8'); expect(logged).toContain('logging.googleapis.com/diagnostic'); expect(logged).toContain('hello world'); }); }); //# sourceMappingURL=pino_gcp_config.spec.js.map