@google-cloud/pino-logging-gcp-config
Version:
Module to create a basic Pino LoggerConfig to support Google Cloud structured logging
323 lines (300 loc) • 10.7 kB
text/typescript
/**
* 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.
*/
/**
* @fileoverview Create a default LoggerConfig for pino structured logging.
*
* @see https://cloud.google.com/logging/docs/structured-logging
*/
import * as pino from 'pino';
import {EventId} from 'eventid';
import {
Logging,
ServiceContext,
detectServiceContext,
} from '@google-cloud/logging';
import {
createDiagnosticEntry,
DIAGNOSTIC_INFO_KEY,
populateInstrumentationInfo,
} from '@google-cloud/logging/build/src/utils/instrumentation';
import * as gax from 'google-gax';
/** Monotonically increasing ID for insertId. */
const eventId = new EventId();
/*
* Uses release-please annotations to update with latest version.
* See
* https://github.com/googleapis/release-please/blob/main/docs/customizing.md#updating-arbitrary-files
*/
const NODEJS_GCP_PINO_LIBRARY_VERSION = '1.0.3'; // {x-release-please-version}
const NODEJS_GCP_PINO_LIBRARY_NAME = 'nodejs-gcppino';
const PINO_TO_GCP_LOG_LEVELS = Object.freeze(
Object.fromEntries([
['trace', 'DEBUG'],
['debug', 'DEBUG'],
['info', 'INFO'],
['warn', 'WARNING'],
['error', 'ERROR'],
['fatal', 'CRITICAL'],
])
) as Record<pino.Level, string>;
/**
* Parameters for configuring GCP logging for Pino, allows specifying
* serviceContext for Error Reporting.
*/
export interface GCPLoggingPinoOptions {
/**
* Optional details of service to be logged, and used in Cloud Error
* Reporting.
*
* If specified, the {@link ServiceContext.service} name must be given.
*
* If not specified, a service name will be auto-detected from the
* environment.
*
* @see https://cloud.google.com/error-reporting/docs/formatting-error-messages
*
*/
serviceContext?: ServiceContext;
/**
* Optional GoogleAuth - used to override defaults when detecting
* ServiceContext from the environment.
*/
auth?: gax.GoogleAuth;
}
/**
* Encapsulates configuration and methods for formatting pino logs for Google
* Cloud Logging.
*/
class GcpLoggingPino {
serviceContext: ServiceContext | null = null;
constructor(options?: GCPLoggingPinoOptions) {
if (options?.serviceContext) {
if (
typeof options.serviceContext?.service !== 'string' ||
!options.serviceContext?.service?.length
) {
throw new Error('options.serviceContext.service must be specified.');
}
this.serviceContext = {...options.serviceContext};
this.outputDiagnosticEntry();
} else {
// Use the Cloud Logging libraries to retrieve the ServiceContext
// automatically from the environment.
//
// This requires initialising a Cloud Logger, then using
// detectServiceContext to asynchronously return the ServiceContext
const cloudLog = new Logging({auth: options?.auth}).logSync(
NODEJS_GCP_PINO_LIBRARY_NAME
);
detectServiceContext(cloudLog.logging.auth).then(
serviceContext => {
this.serviceContext = serviceContext;
this.outputDiagnosticEntry();
},
() => {
// Ignore any errors raised by detectServiceContext.
// Errors can occur if not running in a GCP environment.
}
);
}
}
/**
* Outputs a single log line once per process containing the diagnostic info
* for this logger.
*
* Note that it is not possible for this package to perform API level logging
* with a line of the form cloud-solutions/pino-logging-gcp-config-v1.0.0
*/
outputDiagnosticEntry() {
const diagEntry = createDiagnosticEntry(
NODEJS_GCP_PINO_LIBRARY_NAME,
NODEJS_GCP_PINO_LIBRARY_VERSION
);
diagEntry.data[DIAGNOSTIC_INFO_KEY].runtime = process.version;
const [entries, isInfoAdded] = populateInstrumentationInfo(diagEntry);
if (!isInfoAdded || entries.length === 0) {
return;
}
pino.version;
// Create a temp pino logger instance just to log this diagnostic entry.
pino
.pino({
level: 'info',
...this.getPinoLoggerOptions(),
})
.info({
...diagEntry.data,
});
}
/**
* Creates a JSON fragment string containing the timestamp in GCP logging
* format.
*
* @example ', "timestamp": { "seconds": 123456789, "nanos": 123000000 }'
*
* Creating a string with seconds/nanos is ~10x faster than formatting the
* timestamp as an ISO string.
*
* @see https://cloud.google.com/logging/docs/agent/logging/configuration#timestamp-processing
*
* As Javascript Date uses millisecond precision, in
* {@link formatLogObject} the logger adds a monotonically increasing insertId
* into the log object to preserve log order inside GCP logging.
*
* @see https://github.com/googleapis/nodejs-logging/blob/main/src/entry.ts#L189
*/
static getGcpLoggingTimestamp() {
const seconds = Date.now() / 1000;
const secondsRounded = Math.floor(seconds);
// The following line is 2x as fast as seconds % 1000
// Uses Math.round, not Math.floor due to JS floating point...
// eg for a Date.now()=1713024754120
// (seconds-secondsRounded)*1000 => 119.99988555908203
const millis = Math.round((seconds - secondsRounded) * 1000);
if (millis !== 0) {
return `,"timestamp":{"seconds":${secondsRounded},"nanos":${millis}000000}`;
} else {
return `,"timestamp":{"seconds":${secondsRounded},"nanos":0}`;
}
}
/**
* Converts pino log level to Google severity level.
*
* @see pino.LoggerOptions.formatters.level
*/
static pinoLevelToGcpSeverity(
pinoSeverityLabel: string,
pinoSeverityLevel: number
): Record<string, unknown> {
const pinoLevel = pinoSeverityLabel as pino.Level;
const severity = PINO_TO_GCP_LOG_LEVELS[pinoLevel] ?? 'INFO';
return {
severity,
level: pinoSeverityLevel,
};
}
/**
* Reformats log entry record for GCP.
*
* * Adds OpenTelemetry properties with correct key.
* * Adds stack_trace if an Error is given in the err property.
* * Adds serviceContext
* * Adds sequential insertId to preserve logging order.
*/
formatLogObject(entry: Record<string, unknown>): Record<string, unknown> {
// OpenTelemetry adds properties trace_id, span_id, trace_flags. If these
// are present, not null and not blank, convert them to the property keys
// specified by GCP logging.
//
// @see https://cloud.google.com/logging/docs/structured-logging#special-payload-fields
// @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#trace-context-fields
if ((entry.trace_id as string | undefined)?.length) {
entry['logging.googleapis.com/trace'] = entry.trace_id;
delete entry.trace_id;
}
if ((entry.span_id as string | undefined)?.length) {
entry['logging.googleapis.com/spanId'] = entry.span_id;
delete entry.span_id;
}
// Trace flags is a bit field even though there is one on defined bit,
// so lets convert it to an int and test against a bitmask.
// @see https://www.w3.org/TR/trace-context/#trace-flags
const trace_flags_bits = parseInt(entry.trace_flags as string);
if (!!trace_flags_bits && (trace_flags_bits & 0x1) === 1) {
entry['logging.googleapis.com/trace_sampled'] = true;
}
delete entry.trace_flags;
if (this.serviceContext) {
entry.serviceContext = this.serviceContext;
}
// If there is an Error, add the stack trace for Event Reporting.
if (entry.err instanceof Error && entry.err.stack) {
entry.stack_trace = entry.err.stack;
}
// Add a sequential EventID.
//
// This is required because Javascript Date has a very coarse granularity
// (millisecond), which makes it quite likely that multiple log entries
// would have the same timestamp.
//
// The GCP Logging API doesn't guarantee to preserve insertion order for
// entries with the same timestamp. The service does use `insertId` as a
// secondary ordering for entries with the same timestamp. `insertId` needs
// to be globally unique (within the project) however.
//
// We use a globally unique monotonically increasing EventId as the
// insertId.
//
// @see https://github.com/googleapis/nodejs-logging/blob/main/src/entry.ts#L189
entry['logging.googleapis.com/insertId'] = eventId.new();
return entry;
}
/**
* Creates a pino.LoggerOptions configured for GCP structured logging.
*/
getPinoLoggerOptions(
pinoOptionsMixin?: pino.LoggerOptions
): pino.LoggerOptions {
const formattersMixin = pinoOptionsMixin?.formatters;
return {
...pinoOptionsMixin,
// Use 'message' instead of 'msg' as log entry message key.
messageKey: 'message',
formatters: {
...formattersMixin,
level: GcpLoggingPino.pinoLevelToGcpSeverity,
log: (entry: Record<string, unknown>) => this.formatLogObject(entry),
},
timestamp: () => GcpLoggingPino.getGcpLoggingTimestamp(),
};
}
}
/**
* Creates a {@link pino.LoggerOptions} object which configures pino to output
* JSON records compatible with
* {@link https://cloud.google.com/logging/docs/structured-logging|Google Cloud structured Logging}.
*
* @param pinoLoggerOptions Additional Pino Logger settings that will be added to
* the returned value.
*
* @example
* const logger = pino.pino(
* createGcpLoggingPinoConfig(
* {
* serviceContext: {
* service: 'my-service',
* version: '1.2.3',
* },
* },
* {
* // Set Pino log level to 'debug'.
* level: 'debug',
* }
* )
* );
*
* logger.info('hello world');
* logger.error(err, 'failure: ' + err);
*/
export function createGcpLoggingPinoConfig(
options?: GCPLoggingPinoOptions,
pinoLoggerOptions?: pino.LoggerOptions
): pino.LoggerOptions {
return new GcpLoggingPino(options).getPinoLoggerOptions(pinoLoggerOptions);
}
export const TEST_ONLY = {
PINO_TO_GCP_LOG_LEVELS,
};