@google-cloud/pino-logging-gcp-config
Version:
Module to create a basic Pino LoggerConfig to support Google Cloud structured logging
320 lines • 13.1 kB
JavaScript
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TEST_ONLY = void 0;
exports.createGcpLoggingPinoConfig = createGcpLoggingPinoConfig;
/**
* @fileoverview Create a default LoggerConfig for pino structured logging.
*
* @see https://cloud.google.com/logging/docs/structured-logging
*/
const pino = require("pino");
const eventid_1 = require("eventid");
const logging_1 = require("@google-cloud/logging");
const instrumentation_1 = require("@google-cloud/logging/build/src/utils/instrumentation");
/** Monotonically increasing ID for insertId. */
const eventId = new eventid_1.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.2.0'; // {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'],
]));
/**
* Encapsulates configuration and methods for formatting pino logs for Google
* Cloud Logging.
*/
class GcpLoggingPino {
constructor(options, pinoLoggerOptionsMixin) {
this.serviceContext = null;
this.traceGoogleCloudProjectId = null;
this.pendingInit = null;
if (options?.serviceContext) {
if (typeof options.serviceContext?.service !== 'string' ||
!options.serviceContext?.service?.length) {
throw new Error('options.serviceContext.service must be specified.');
}
}
this.pinoLoggerOptions = this.buidPinoLoggerOptions(pinoLoggerOptionsMixin);
const promises = this.initializeOptions(options);
if (!options?.inihibitDiagnosticMessage) {
if (promises.length > 0) {
// Some async actions pending, wait for them then log diagnostic.
const pinoDestinationStream = options?.pinoDestinationStream;
this.pendingInit = Promise.all(promises).then(() => {
this.outputDiagnosticEntry(pinoDestinationStream);
});
}
else {
// no async options pending, directly log diagnostic.
this.outputDiagnosticEntry(options?.pinoDestinationStream);
}
}
}
/**
* Resolves and initializes the configuration options for the logger.
*
* This function sets up the `serviceContext` and `traceGoogleCloudProjectId`
* properties for the logger. It uses the provided options or attempts to
* auto-detect values from the environment if they are not specified.
*
* @param options Configuration options for GCP logging.
* @return an array of promises for async init actions.
*/
initializeOptions(options) {
let auth = options?.auth;
const promises = [];
if (options?.serviceContext) {
this.serviceContext = { ...options.serviceContext };
}
else {
if (!auth) {
// Get default auth via logging library.
auth = new logging_1.Logging().auth;
}
promises.push(
// Use detectServiceContext to asynchronously return the ServiceContext
(0, logging_1.detectServiceContext)(auth).then(serviceContext => {
this.serviceContext = serviceContext;
}, () => { } // ignore any raised errors.
));
}
if (options?.traceGoogleCloudProjectId !== undefined &&
options?.traceGoogleCloudProjectId !== null) {
if (options?.traceGoogleCloudProjectId.length > 0) {
// empty string keeps project ID as null
this.traceGoogleCloudProjectId = options.traceGoogleCloudProjectId;
}
}
else {
if (!auth) {
// Get default auth from logging library.
auth = new logging_1.Logging().auth;
}
promises.push(
// Using GoogleAuth to get the projectId from the environment.
auth.getProjectId().then(projectId => {
this.traceGoogleCloudProjectId = projectId;
}, () => { } // ignore any raised errors.
));
}
return promises;
}
/**
* 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(pinoDestinationStream) {
try {
const diagEntry = (0, instrumentation_1.createDiagnosticEntry)(NODEJS_GCP_PINO_LIBRARY_NAME, NODEJS_GCP_PINO_LIBRARY_VERSION);
diagEntry.data[instrumentation_1.DIAGNOSTIC_INFO_KEY].runtime = process.version;
const [entries, isInfoAdded] = (0, instrumentation_1.populateInstrumentationInfo)(diagEntry);
if (!isInfoAdded || entries.length === 0) {
return;
}
// Create a temp pino logger instance just to log this diagnostic entry.
pino
.pino({
...this.pinoLoggerOptions,
level: 'info',
}, pinoDestinationStream)
.info({
...diagEntry.data,
});
}
catch {
// ignore any errors.
}
}
/**
* 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, pinoSeverityLevel) {
const pinoLevel = pinoSeverityLabel;
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(input) {
// 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
const entry = { ...input };
if (entry.trace_id?.length) {
entry['logging.googleapis.com/trace'] = this.traceGoogleCloudProjectId
? `projects/${this.traceGoogleCloudProjectId}/traces/${entry.trace_id}`
: entry.trace_id;
delete entry.trace_id;
}
if (entry.span_id?.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);
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.
*/
buidPinoLoggerOptions(pinoOptionsMixin) {
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) => {
// If a formattersMixin.log function is provided, call it first to allow user preprocessing
const preprocessedEntry = formattersMixin?.log
? formattersMixin.log(entry)
: entry;
return this.formatLogObject(preprocessedEntry);
},
},
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 pinoLoggerOptionsMixin Additional Pino Logger settings that will be added to
* the returned value.
*
* @throws {Error} If `serviceContext.service` is provided but is not a valid
* string or is empty.
*
* @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);
*/
function createGcpLoggingPinoConfig(options, pinoLoggerOptionsMixin) {
return new GcpLoggingPino(options, pinoLoggerOptionsMixin).pinoLoggerOptions;
}
exports.TEST_ONLY = {
PINO_TO_GCP_LOG_LEVELS,
GcpLoggingPino,
};
//# sourceMappingURL=pino_gcp_config.js.map
;