Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e7106a3
fix(logger): pino pagerduty transport
Reinis-FRP Dec 19, 2025
4a52056
wip
Reinis-FRP Jan 15, 2026
6acb1b1
wip(logger): add pino pagerduty v2 transport with shared config
Reinis-FRP Jan 19, 2026
a496d25
fix(logger): use import type syntax for linter compatibility
Reinis-FRP Jan 20, 2026
928064e
refactor(logger): remove unnecessary PagerDuty re-exports
Reinis-FRP Jan 20, 2026
86e36e7
refactor(logger): remove unused environment param from Pino Transport…
Reinis-FRP Jan 20, 2026
349e9f4
fix(logger): properly mutate obj.mrkdwn in Pino PagerDuty transport
Reinis-FRP Jan 20, 2026
12ca0c6
refactor(logger): simplify PagerDuty event sending with shared helper
Reinis-FRP Jan 20, 2026
e7f3b8e
refactor(logger): move markdown cleaning into sendPagerDutyEvent
Reinis-FRP Jan 20, 2026
ff09b1a
refactor(logger): move PagerDuty shared code to shared/PagerDutyV2Tra…
Reinis-FRP Jan 20, 2026
1cdbd38
refactor(logger): simplify PagerDuty payload construction
Reinis-FRP Jan 20, 2026
a292bff
fix(logger): add missing semicolon
Reinis-FRP Jan 20, 2026
01edd52
fix(logger): fix Pino PagerDuty transport to correctly parse logs
Reinis-FRP Jan 20, 2026
10c160b
feat(logger): add error serializers to Pino logger
Reinis-FRP Jan 20, 2026
7a82824
refactor(logger): use Pino levels.labels in convertLevelToSeverity
Reinis-FRP Jan 20, 2026
4f2f9fb
fix(logger): correct severity mapping for unmapped log levels
Reinis-FRP Jan 20, 2026
ef55b58
test(logger): add comprehensive tests for PagerDuty V2 transports
Reinis-FRP Jan 20, 2026
9f838c1
revert(discord-ticket-api): revert server.ts to master version
Reinis-FRP Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/logger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from "./logger/Logger";
export * from "./logger/SpyTransport";
export * from "./logger/ConsoleTransport";
export * from "./logger/Formatters";
export * from "./pinoLogger";
export * from "./pinoLogger/Logger";
export * from "./pinoLogger/Transports";
48 changes: 3 additions & 45 deletions packages/logger/src/logger/PagerDutyV2Transport.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,11 @@
// This transport enables winston logging to send messages to pager duty v2 api.
import Transport from "winston-transport";
import { event } from "@pagerduty/pdjs";
import * as ss from "superstruct";

import { removeAnchorTextFromLinks } from "./Formatters";
import { TransportError } from "./TransportError";
import type { Config } from "../shared/PagerDutyV2Transport";
import { sendPagerDutyEvent } from "../shared/PagerDutyV2Transport";

type TransportOptions = ConstructorParameters<typeof Transport>[0];
export type Severity = "critical" | "error" | "warning" | "info";
export type Action = "trigger" | "acknowledge" | "resolve";

const Config = ss.object({
integrationKey: ss.string(),
customServices: ss.optional(ss.record(ss.string(), ss.string())),
logTransportErrors: ss.optional(ss.boolean()),
});
// Config object becomes a type
// {
// integrationKey: string;
// customServices?: Record<string,string>;
// logTransportErrors?: boolean;
// }
export type Config = ss.Infer<typeof Config>;

// this turns an unknown ( like json parsed data) into a config, or throws an error
export function createConfig(config: unknown): Config {
return ss.create(config, Config);
}

export class PagerDutyV2Transport extends Transport {
private readonly integrationKey: string;
Expand All @@ -41,33 +20,12 @@ export class PagerDutyV2Transport extends Transport {
this.customServices = customServices;
this.logTransportErrors = logTransportErrors;
}
// pd v2 severity only supports critical, error, warning or info.
public static convertLevelToSeverity(level?: string): Severity {
if (!level) return "error";
if (level === "warn") return "warning";
if (level === "info" || level === "critical") return level;
return "error";
}
// Note: info must be any because that's what the base class uses.
async log(info: any, callback: (error?: unknown) => void): Promise<void> {
try {
// we route to different pd services using the integration key (routing_key), or multiple services with the custom services object
const routing_key = this.customServices[info.notificationPath] ?? this.integrationKey;
// PagerDuty does not support anchor text in links, so we remove it from markdown if it exists.
if (typeof info.mrkdwn === "string") info.mrkdwn = removeAnchorTextFromLinks(info.mrkdwn);
await event({
data: {
routing_key,
event_action: "trigger" as Action,
payload: {
summary: `${info.level}: ${info.at} ⭢ ${info.message}`,
severity: PagerDutyV2Transport.convertLevelToSeverity(info.level),
source: info["bot-identifier"] ? info["bot-identifier"] : undefined,
// we can put any structured data in here as long as it is can be repped as json
custom_details: info,
},
},
});
await sendPagerDutyEvent(routing_key, info);
} catch (error) {
// We don't want to emit error if this same transport is used to log transport errors to avoid recursion.
if (!this.logTransportErrors) return callback(new TransportError("PagerDuty V2", error, info));
Expand Down
8 changes: 3 additions & 5 deletions packages/logger/src/logger/Transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@ import {
createConfig as discordTicketCreateConfig,
DiscordTicketTransport,
} from "./DiscordTicketTransport";
import {
PagerDutyV2Transport,
Config as PagerDutyV2Config,
createConfig as pagerDutyV2CreateConfig,
} from "./PagerDutyV2Transport";
import { PagerDutyV2Transport } from "./PagerDutyV2Transport";
import type { Config as PagerDutyV2Config } from "../shared/PagerDutyV2Transport";
import { createConfig as pagerDutyV2CreateConfig } from "../shared/PagerDutyV2Transport";
import { DiscordTransport } from "./DiscordTransport";
import type Transport from "winston-transport";
import dotenv from "dotenv";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { pino, LevelWithSilentOrString, Logger as PinoLogger, LoggerOptions as PinoLoggerOptions } from "pino";
import {
pino,
LevelWithSilentOrString,
Logger as PinoLogger,
LoggerOptions as PinoLoggerOptions,
stdSerializers,
} from "pino";
import { createGcpLoggingPinoConfig } from "@google-cloud/pino-logging-gcp-config";
import { noBotId } from "./constants";
import { generateRandomRunId } from "./logger/Logger";
import { noBotId } from "../constants";
import { generateRandomRunId } from "../logger/Logger";
import { createPinoTransports } from "./Transports";

export type { PinoLogger };
export type { PinoLoggerOptions };
Expand All @@ -17,16 +24,26 @@ export function createPinoLogger({
runIdentifier = process.env.RUN_IDENTIFIER || generateRandomRunId(),
level = "info",
}: Partial<CustomPinoLoggerOptions> = {}): PinoLogger {
return pino(createPinoConfig({ botIdentifier, runIdentifier, level }));
return pino(createPinoConfig({ botIdentifier, runIdentifier, level }), createPinoTransports({ level }));
}

export function createPinoConfig({
botIdentifier = process.env.BOT_IDENTIFIER || noBotId,
runIdentifier = process.env.RUN_IDENTIFIER || generateRandomRunId(),
level = "info",
}: Partial<CustomPinoLoggerOptions> = {}): PinoLoggerOptions {
return createGcpLoggingPinoConfig(undefined, {
const gcpConfig = createGcpLoggingPinoConfig(undefined, {
level,
base: { "bot-identifier": botIdentifier, "run-identifier": runIdentifier },
});

// Add error serializers to properly log Error objects (matching Winston behavior)
return {
...gcpConfig,
serializers: {
...gcpConfig.serializers,
err: stdSerializers.err,
error: stdSerializers.err, // Use err serializer for 'error' field too
},
};
}
23 changes: 23 additions & 0 deletions packages/logger/src/pinoLogger/PagerDutyV2Transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// This transport enables pino logging to send messages to PagerDuty v2 API.
// Pino transports run in worker threads for performance, so they can import dependencies.
import build from "pino-abstract-transport";
import type { Transform } from "stream";
import type { Config } from "../shared/PagerDutyV2Transport";
import { createConfig, sendPagerDutyEvent } from "../shared/PagerDutyV2Transport";

export default async function (opts: Config): Promise<Transform & build.OnUnknown> {
const config = createConfig(opts);

return build(async function (source) {
for await (const obj of source) {
try {
// Get routing key from custom services or use default integration key
const routing_key = config.customServices?.[obj.notificationPath] ?? config.integrationKey;
await sendPagerDutyEvent(routing_key, obj);
} catch (error) {
// Always log transport errors in Pino since there's no callback mechanism like Winston
console.error("PagerDuty v2 transport error:", error);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add more context here like:

  console.error("PagerDuty v2 transport error:", error, { logObj: obj });  

}
}
});
}
46 changes: 46 additions & 0 deletions packages/logger/src/pinoLogger/Transports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { transport, TransportTargetOptions } from "pino";
import type { Config as PagerDutyV2Config } from "../shared/PagerDutyV2Transport";
import { createConfig as pagerDutyV2CreateConfig } from "../shared/PagerDutyV2Transport";
import dotenv from "dotenv";
import minimist from "minimist";
import path from "path";

dotenv.config();
const argv = minimist(process.argv.slice(), {});

interface TransportsConfig {
level?: string;
pagerDutyV2Config?: PagerDutyV2Config & { disabled?: boolean };
}

export function createPinoTransports(transportsConfig: TransportsConfig = {}): ReturnType<typeof transport> {
const targets: TransportTargetOptions[] = [];
const level = transportsConfig.level || process.env.LOG_LEVEL || "info";

// stdout transport (for GCP Logging and local dev)
targets.push({
target: "pino/file",
level,
options: { destination: 1 },
});

// Skip additional transports in test environment
if (argv._.indexOf("test") === -1) {
// Add PagerDuty V2 transport if configured
if (transportsConfig.pagerDutyV2Config || process.env.PAGER_DUTY_V2_CONFIG) {
// to disable pdv2, pass in a "disabled=true" in configs or env.
const { disabled = false, ...pagerDutyV2Config } =
transportsConfig.pagerDutyV2Config ?? JSON.parse(process.env.PAGER_DUTY_V2_CONFIG || "null");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if PAGER_DUTY_V2_CONFIG contains an invalid json this will throw an unhandled error. Should we try catch this and so we have a more descriptive error message?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Eventually adding a test with an invalid config could be nice.

// this will throw an error if an invalid configuration is present
if (!disabled) {
targets.push({
target: path.join(__dirname, "PagerDutyV2Transport.js"),
level: "error",
options: pagerDutyV2CreateConfig(pagerDutyV2Config),
});
}
}
}

return transport({ targets });
}
68 changes: 68 additions & 0 deletions packages/logger/src/shared/PagerDutyV2Transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Shared PagerDuty V2 configuration and utilities
// Used by both Winston and Pino PagerDuty transports
import * as ss from "superstruct";
import { event } from "@pagerduty/pdjs";
import { levels } from "pino";
import { removeAnchorTextFromLinks } from "../logger/Formatters";

export type Severity = "critical" | "error" | "warning" | "info";
export type Action = "trigger" | "acknowledge" | "resolve";

const Config = ss.object({
integrationKey: ss.string(),
customServices: ss.optional(ss.record(ss.string(), ss.string())),
logTransportErrors: ss.optional(ss.boolean()),
});

export type Config = ss.Infer<typeof Config>;

// This turns an unknown (like json parsed data) into a config, or throws an error
export function createConfig(config: unknown): Config {
return ss.create(config, Config);
}

// PD v2 severity only supports critical, error, warning or info.
// Handles both Winston string levels and Pino numeric levels.
export function convertLevelToSeverity(level?: string | number): Severity {
if (!level) return "info";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice Winston was returning an error if if (!level), but agree it makes more sense to return info here


// Convert numeric Pino levels to string names using Pino's built-in mapping
const levelStr = typeof level === "number" ? levels.labels[level] : String(level).toLowerCase();

// Map level names to PagerDuty severity values
if (levelStr === "fatal") return "critical";
if (levelStr === "error") return "error";
if (levelStr === "warn") return "warning";
if (levelStr === "info") return "info";
if (levelStr === "critical") return "critical";

// Unknown/unmapped levels (debug, trace, etc.) default to lowest severity
return "info";
}

// Send event to PagerDuty V2 API
// Accepts the whole log object and routing key, extracts necessary fields
export async function sendPagerDutyEvent(routing_key: string, logObj: any): Promise<void> {
// PagerDuty does not support anchor text in links, so we remove it from markdown if it exists.
if (typeof logObj.mrkdwn === "string") {
logObj.mrkdwn = removeAnchorTextFromLinks(logObj.mrkdwn);
}

// Convert numeric Pino levels to strings for summary (Winston already uses strings)
const levelStr = typeof logObj.level === "number" ? levels.labels[logObj.level] : logObj.level;

const payload: any = {
summary: `${levelStr}: ${logObj.at} ⭢ ${logObj.message}`,
severity: convertLevelToSeverity(logObj.level),
source: logObj["bot-identifier"] ? logObj["bot-identifier"] : undefined,
custom_details: logObj,
};

await event({
data: {
routing_key,
event_action: "trigger" as Action,
payload,
},
});
}
Loading