diff --git a/.changesets/deprecate-heartbeats.md b/.changesets/deprecate-heartbeats.md new file mode 100644 index 00000000..72d88543 --- /dev/null +++ b/.changesets/deprecate-heartbeats.md @@ -0,0 +1,6 @@ +--- +bump: patch +type: deprecate +--- + +Calls to `Appsignal.heartbeat` and to the `Appsignal.Heartbeat` constructor will emit a deprecation warning. diff --git a/.changesets/rename-heartbeats-to-cron-check-ins.md b/.changesets/rename-heartbeats-to-cron-check-ins.md new file mode 100644 index 00000000..dac7bafb --- /dev/null +++ b/.changesets/rename-heartbeats-to-cron-check-ins.md @@ -0,0 +1,22 @@ +--- +bump: patch +type: change +--- + +Rename heartbeats to cron check-ins. Calls to `Appsignal.heartbeat` and `Appsignal.Heartbeat` should be replaced with calls to `Appsignal.checkIn.cron` and `Appsignal.checkIn.Cron`, for example: + +```js +// Before +import { heartbeat } from "@appsignal/nodejs" + +heartbeat("do_something", () => { + do_something() +}) + +// After +import { checkIn } from "@appsignal/nodejs" + +checkIn.cron("do_something", () => { + do_something +}) +``` diff --git a/src/__tests__/check_in.test.ts b/src/__tests__/check_in.test.ts new file mode 100644 index 00000000..f38df11e --- /dev/null +++ b/src/__tests__/check_in.test.ts @@ -0,0 +1,390 @@ +import nock, { Scope } from "nock" +import { cron, Cron, EventKind } from "../check_in" +import { Client, Options } from "../client" +import { + heartbeat, + Heartbeat, + heartbeatClassWarnOnce, + heartbeatHelperWarnOnce +} from "../heartbeat" + +const DEFAULT_CLIENT_CONFIG: Partial = { + active: true, + name: "Test App", + pushApiKey: "test-push-api-key", + environment: "test", + hostname: "test-hostname" +} + +function mockCronCheckInRequest( + kind: EventKind, + { delay } = { delay: 0 } +): Scope { + return nock("https://appsignal-endpoint.net:443") + .post("/check_ins/json", body => { + return ( + body.identifier === "test-cron-checkin" && + body.kind === kind && + body.check_in_type === "cron" + ) + }) + .query({ + api_key: "test-push-api-key", + name: "Test App", + environment: "test", + hostname: "test-hostname" + }) + .delay(delay) + .reply(200, "") +} + +function nextTick(fn: () => void): Promise { + return new Promise(resolve => { + process.nextTick(() => { + fn() + resolve() + }) + }) +} + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + +function interceptRequestBody(scope: Scope): Promise { + return new Promise(resolve => { + scope.on("request", (_req, _interceptor, body: string) => { + resolve(body) + }) + }) +} + +describe("checkIn.Cron", () => { + let client: Client + let theCron: Cron + + beforeAll(() => { + theCron = new Cron("test-cron-checkin") + + if (!nock.isActive()) { + nock.activate() + } + }) + + beforeEach(() => { + client = new Client(DEFAULT_CLIENT_CONFIG) + + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + client.stop() + }) + + afterAll(() => { + nock.restore() + }) + + it("does not transmit any events when AppSignal is not active", async () => { + client.stop() + client = new Client({ + ...DEFAULT_CLIENT_CONFIG, + active: false + }) + + const startScope = mockCronCheckInRequest("start") + const finishScope = mockCronCheckInRequest("finish") + + await expect(theCron.start()).resolves.toBeUndefined() + await expect(theCron.finish()).resolves.toBeUndefined() + + expect(startScope.isDone()).toBe(false) + expect(finishScope.isDone()).toBe(false) + }) + + it("cron.start() sends a cron check-in start event", async () => { + const scope = mockCronCheckInRequest("start") + + await expect(theCron.start()).resolves.toBeUndefined() + + scope.done() + }) + + it("cron.finish() sends a cron check-in finish event", async () => { + const scope = mockCronCheckInRequest("finish") + + await expect(theCron.finish()).resolves.toBeUndefined() + + scope.done() + }) + + it("Cron.shutdown() awaits pending cron check-in event promises", async () => { + const startScope = mockCronCheckInRequest("start", { delay: 100 }) + const finishScope = mockCronCheckInRequest("finish", { delay: 200 }) + + let finishPromiseResolved = false + let shutdownPromiseResolved = false + + const startPromise = theCron.start() + + theCron.finish().then(() => { + finishPromiseResolved = true + }) + + const shutdownPromise = Cron.shutdown().then(() => { + shutdownPromiseResolved = true + }) + + await expect(startPromise).resolves.toBeUndefined() + + // The finish promise should still be pending, so the shutdown promise + // should not be resolved yet. + await nextTick(() => { + expect(finishPromiseResolved).toBe(false) + expect(shutdownPromiseResolved).toBe(false) + }) + + startScope.done() + + // The shutdown promise should not resolve until the finish promise + // resolves. + await expect(shutdownPromise).resolves.toBeUndefined() + + await nextTick(() => { + expect(finishPromiseResolved).toBe(true) + }) + + finishScope.done() + }) + + describe("Appsignal.checkIn.cron()", () => { + it("without a function, sends a cron check-in finish event", async () => { + const startScope = mockCronCheckInRequest("start") + const finishScope = mockCronCheckInRequest("finish") + + expect(cron("test-cron-checkin")).toBeUndefined() + + await nextTick(() => { + expect(startScope.isDone()).toBe(false) + finishScope.done() + }) + }) + + describe("with a function", () => { + it("sends cron check-in start and finish events", async () => { + const startScope = mockCronCheckInRequest("start") + const startBody = interceptRequestBody(startScope) + + const finishScope = mockCronCheckInRequest("finish") + const finishBody = interceptRequestBody(finishScope) + + expect( + cron("test-cron-checkin", () => { + const thisSecond = Math.floor(Date.now() / 1000) + + // Since this function must be synchronous, we need to deadlock + // until the next second in order to obtain different timestamps + // for the start and finish events. + // eslint-disable-next-line no-constant-condition + while (true) { + if (Math.floor(Date.now() / 1000) != thisSecond) break + } + + return "output" + }) + ).toBe("output") + + // Since the function is synchronous and deadlocks, the start and + // finish events' requests are actually initiated simultaneously + // afterwards, when the function finishes and the event loop ticks. + await nextTick(() => { + startScope.done() + finishScope.done() + }) + + expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan( + JSON.parse(await startBody).timestamp + ) + }) + + it("does not send a finish event when the function throws an error", async () => { + const startScope = mockCronCheckInRequest("start") + const finishScope = mockCronCheckInRequest("finish") + + expect(() => { + cron("test-cron-checkin", () => { + throw new Error("thrown") + }) + }).toThrow("thrown") + + await nextTick(() => { + startScope.done() + expect(finishScope.isDone()).toBe(false) + }) + }) + }) + + describe("with an async function", () => { + it("sends cron check-in start and finish events", async () => { + const startScope = mockCronCheckInRequest("start") + const startBody = interceptRequestBody(startScope) + + const finishScope = mockCronCheckInRequest("finish") + const finishBody = interceptRequestBody(finishScope) + + await expect( + cron("test-cron-checkin", async () => { + await nextTick(() => { + startScope.done() + expect(finishScope.isDone()).toBe(false) + }) + + const millisecondsToNextSecond = 1000 - (Date.now() % 1000) + await sleep(millisecondsToNextSecond) + + return "output" + }) + ).resolves.toBe("output") + + await nextTick(() => { + startScope.done() + finishScope.done() + }) + + expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan( + JSON.parse(await startBody).timestamp + ) + }) + + it("does not send a finish event when the promise returned is rejected", async () => { + const startScope = mockCronCheckInRequest("start") + const finishScope = mockCronCheckInRequest("finish") + + await expect( + cron("test-cron-checkin", async () => { + await nextTick(() => { + startScope.done() + expect(finishScope.isDone()).toBe(false) + }) + + throw new Error("rejected") + }) + ).rejects.toThrow("rejected") + + await nextTick(() => { + startScope.done() + expect(finishScope.isDone()).toBe(false) + }) + }) + }) + }) + + describe("Appsignal.heartbeat (deprecated)", () => { + beforeEach(() => { + heartbeatHelperWarnOnce.reset() + }) + + it("behaves like Appsignal.checkIn.cron", async () => { + const startScope = mockCronCheckInRequest("start") + const finishScope = mockCronCheckInRequest("finish") + + expect(heartbeat("test-cron-checkin")).toBeUndefined() + + await nextTick(() => { + expect(startScope.isDone()).toBe(false) + finishScope.done() + }) + }) + + it("emits a warning when called", async () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() + const internalLoggerWarnSpy = jest + .spyOn(Client.internalLogger, "warn") + .mockImplementation() + + expect(heartbeat("test-cron-checkin")).toBeUndefined() + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(1) + expect(spy.mock.calls[0][0]).toMatch( + /The helper `heartbeat` has been deprecated./ + ) + } + }) + + it("only emits a warning on the first call", async () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() + const internalLoggerWarnSpy = jest + .spyOn(Client.internalLogger, "warn") + .mockImplementation() + + expect(heartbeat("test-cron-checkin")).toBeUndefined() + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(1) + expect(spy.mock.calls[0][0]).toMatch( + /The helper `heartbeat` has been deprecated./ + ) + spy.mockClear() + } + + expect(heartbeat("test-cron-checkin")).toBeUndefined() + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(0) + } + }) + }) + + describe("Appsignal.Heartbeat (deprecated)", () => { + beforeEach(() => { + heartbeatClassWarnOnce.reset() + }) + + it("returns an Appsignal.checkIn.Cron instance on initialisation", async () => { + expect(new Heartbeat("test-cron-checkin")).toBeInstanceOf(Cron) + }) + + it("emits a warning when called", async () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() + const internalLoggerWarnSpy = jest + .spyOn(Client.internalLogger, "warn") + .mockImplementation() + + expect(new Heartbeat("test-cron-checkin")).toBeInstanceOf(Cron) + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(1) + expect(spy.mock.calls[0][0]).toMatch( + /The class `Heartbeat` has been deprecated./ + ) + } + }) + + it("only emits a warning on the first call", async () => { + const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation() + const internalLoggerWarnSpy = jest + .spyOn(Client.internalLogger, "warn") + .mockImplementation() + + expect(new Heartbeat("test-cron-checkin")).toBeInstanceOf(Cron) + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(1) + expect(spy.mock.calls[0][0]).toMatch( + /The class `Heartbeat` has been deprecated./ + ) + spy.mockClear() + } + + expect(new Heartbeat("test-cron-checkin")).toBeInstanceOf(Cron) + + for (const spy of [consoleWarnSpy, internalLoggerWarnSpy]) { + expect(spy.mock.calls).toHaveLength(0) + } + }) + }) +}) diff --git a/src/__tests__/heartbeat.test.ts b/src/__tests__/heartbeat.test.ts deleted file mode 100644 index 3ffc0a1c..00000000 --- a/src/__tests__/heartbeat.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import nock, { Scope } from "nock" -import { heartbeat, Heartbeat, EventKind } from "../heartbeat" -import { Client, Options } from "../client" - -const DEFAULT_CLIENT_CONFIG: Partial = { - active: true, - name: "Test App", - pushApiKey: "test-push-api-key", - environment: "test", - hostname: "test-hostname" -} - -function mockHeartbeatRequest( - kind: EventKind, - { delay } = { delay: 0 } -): Scope { - return nock("https://appsignal-endpoint.net:443") - .post("/heartbeats/json", body => { - return body.name === "test-heartbeat" && body.kind === kind - }) - .query({ - api_key: "test-push-api-key", - name: "Test App", - environment: "test", - hostname: "test-hostname" - }) - .delay(delay) - .reply(200, "") -} - -function nextTick(fn: () => void): Promise { - return new Promise(resolve => { - process.nextTick(() => { - fn() - resolve() - }) - }) -} - -function sleep(ms: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, ms) - }) -} - -function interceptRequestBody(scope: Scope): Promise { - return new Promise(resolve => { - scope.on("request", (_req, _interceptor, body: string) => { - resolve(body) - }) - }) -} - -describe("Heartbeat", () => { - let client: Client - let theHeartbeat: Heartbeat - - beforeAll(() => { - theHeartbeat = new Heartbeat("test-heartbeat") - - if (!nock.isActive()) { - nock.activate() - } - }) - - beforeEach(() => { - client = new Client(DEFAULT_CLIENT_CONFIG) - - nock.cleanAll() - nock.disableNetConnect() - }) - - afterEach(() => { - client.stop() - }) - - afterAll(() => { - nock.restore() - }) - - it("does not transmit any events when AppSignal is not active", async () => { - client.stop() - client = new Client({ - ...DEFAULT_CLIENT_CONFIG, - active: false - }) - - const startScope = mockHeartbeatRequest("start") - const finishScope = mockHeartbeatRequest("finish") - - await expect(theHeartbeat.start()).resolves.toBeUndefined() - await expect(theHeartbeat.finish()).resolves.toBeUndefined() - - expect(startScope.isDone()).toBe(false) - expect(finishScope.isDone()).toBe(false) - }) - - it("heartbeat.start() sends a heartbeat start event", async () => { - const scope = mockHeartbeatRequest("start") - - await expect(theHeartbeat.start()).resolves.toBeUndefined() - - scope.done() - }) - - it("heartbeat.finish() sends a heartbeat finish event", async () => { - const scope = mockHeartbeatRequest("finish") - - await expect(theHeartbeat.finish()).resolves.toBeUndefined() - - scope.done() - }) - - it("Heartbeat.shutdown() awaits pending heartbeat event promises", async () => { - const startScope = mockHeartbeatRequest("start", { delay: 100 }) - const finishScope = mockHeartbeatRequest("finish", { delay: 200 }) - - let finishPromiseResolved = false - let shutdownPromiseResolved = false - - const startPromise = theHeartbeat.start() - - theHeartbeat.finish().then(() => { - finishPromiseResolved = true - }) - - const shutdownPromise = Heartbeat.shutdown().then(() => { - shutdownPromiseResolved = true - }) - - await expect(startPromise).resolves.toBeUndefined() - - // The finish promise should still be pending, so the shutdown promise - // should not be resolved yet. - await nextTick(() => { - expect(finishPromiseResolved).toBe(false) - expect(shutdownPromiseResolved).toBe(false) - }) - - startScope.done() - - // The shutdown promise should not resolve until the finish promise - // resolves. - await expect(shutdownPromise).resolves.toBeUndefined() - - await nextTick(() => { - expect(finishPromiseResolved).toBe(true) - }) - - finishScope.done() - }) - - describe("Appsignal.heartbeat()", () => { - it("without a function, sends a heartbeat finish event", async () => { - const startScope = mockHeartbeatRequest("start") - const finishScope = mockHeartbeatRequest("finish") - - expect(heartbeat("test-heartbeat")).toBeUndefined() - - await nextTick(() => { - expect(startScope.isDone()).toBe(false) - finishScope.done() - }) - }) - - describe("with a function", () => { - it("sends heartbeat start and finish events", async () => { - const startScope = mockHeartbeatRequest("start") - const startBody = interceptRequestBody(startScope) - - const finishScope = mockHeartbeatRequest("finish") - const finishBody = interceptRequestBody(finishScope) - - expect( - heartbeat("test-heartbeat", () => { - const thisSecond = Math.floor(Date.now() / 1000) - - // Since this function must be synchronous, we need to deadlock - // until the next second in order to obtain different timestamps - // for the start and finish events. - // eslint-disable-next-line no-constant-condition - while (true) { - if (Math.floor(Date.now() / 1000) != thisSecond) break - } - - return "output" - }) - ).toBe("output") - - // Since the function is synchronous and deadlocks, the start and - // finish events' requests are actually initiated simultaneously - // afterwards, when the function finishes and the event loop ticks. - await nextTick(() => { - startScope.done() - finishScope.done() - }) - - expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan( - JSON.parse(await startBody).timestamp - ) - }) - - it("does not send a finish event when the function throws an error", async () => { - const startScope = mockHeartbeatRequest("start") - const finishScope = mockHeartbeatRequest("finish") - - expect(() => { - heartbeat("test-heartbeat", () => { - throw new Error("thrown") - }) - }).toThrow("thrown") - - await nextTick(() => { - startScope.done() - expect(finishScope.isDone()).toBe(false) - }) - }) - }) - - describe("with an async function", () => { - it("sends heartbeat start and finish events", async () => { - const startScope = mockHeartbeatRequest("start") - const startBody = interceptRequestBody(startScope) - - const finishScope = mockHeartbeatRequest("finish") - const finishBody = interceptRequestBody(finishScope) - - await expect( - heartbeat("test-heartbeat", async () => { - await nextTick(() => { - startScope.done() - expect(finishScope.isDone()).toBe(false) - }) - - const millisecondsToNextSecond = 1000 - (Date.now() % 1000) - await sleep(millisecondsToNextSecond) - - return "output" - }) - ).resolves.toBe("output") - - await nextTick(() => { - startScope.done() - finishScope.done() - }) - - expect(JSON.parse(await finishBody).timestamp).toBeGreaterThan( - JSON.parse(await startBody).timestamp - ) - }) - - it("does not send a finish event when the promise returned is rejected", async () => { - const startScope = mockHeartbeatRequest("start") - const finishScope = mockHeartbeatRequest("finish") - - await expect( - heartbeat("test-heartbeat", async () => { - await nextTick(() => { - startScope.done() - expect(finishScope.isDone()).toBe(false) - }) - - throw new Error("rejected") - }) - ).rejects.toThrow("rejected") - - await nextTick(() => { - startScope.done() - expect(finishScope.isDone()).toBe(false) - }) - }) - }) - }) -}) diff --git a/src/check_in.ts b/src/check_in.ts new file mode 100644 index 00000000..2ccae270 --- /dev/null +++ b/src/check_in.ts @@ -0,0 +1,117 @@ +import crypto from "crypto" +import { Transmitter } from "./transmitter" +import { Client } from "./client" + +export type EventKind = "start" | "finish" + +export type Event = { + identifier: string + digest: string + kind: EventKind + timestamp: number + check_in_type: "cron" +} + +class PendingPromiseSet extends Set> { + add(promise: Promise) { + super.add(promise) + promise.finally(() => this.delete(promise)) + return this + } + + async allSettled() { + await Promise.allSettled(this) + } +} + +export class Cron { + private static cronPromises = new PendingPromiseSet() + + identifier: string + digest: string + + constructor(identifier: string) { + this.identifier = identifier + this.digest = crypto.randomBytes(8).toString("hex") + } + + public static async shutdown() { + await Cron.cronPromises.allSettled() + } + + public start(): Promise { + return this.transmit(this.event("start")) + } + + public finish(): Promise { + return this.transmit(this.event("finish")) + } + + private event(kind: EventKind): Event { + return { + identifier: this.identifier, + digest: this.digest, + kind: kind, + timestamp: Math.floor(Date.now() / 1000), + check_in_type: "cron" + } + } + + private transmit(event: Event): Promise { + if (Client.client === undefined || !Client.client.isActive) { + Client.internalLogger.debug( + "AppSignal not active, not transmitting cron check-in event" + ) + return Promise.resolve() + } + + const promise = new Transmitter( + `${Client.config.data.loggingEndpoint}/check_ins/json`, + JSON.stringify(event) + ).transmit() + + const handledPromise = promise + .then(({ status }: { status: number }) => { + if (status >= 200 && status <= 299) { + Client.internalLogger.trace( + `Transmitted cron check-in \`${event.identifier}\` (${event.digest}) ${event.kind} event` + ) + } else { + Client.internalLogger.warn( + `Failed to transmit cron check-in ${event.kind} event: status code was ${status}` + ) + } + }) + .catch(({ error }: { error: Error }) => { + Client.internalLogger.warn( + `Failed to transmit cron check-in ${event.kind} event: ${error.message}` + ) + + return Promise.resolve() + }) + + Cron.cronPromises.add(handledPromise) + + return handledPromise + } +} + +export function cron(identifier: string): void +export function cron(identifier: string, fn: () => T): T +export function cron(identifier: string, fn?: () => T): T | undefined { + const cron = new Cron(identifier) + let output + + if (fn) { + cron.start() + output = fn() + } + + if (output instanceof Promise) { + output.then(() => cron.finish()).catch(() => {}) + } else { + cron.finish() + } + + return output +} diff --git a/src/client.ts b/src/client.ts index ee93ed61..34c155a9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -46,7 +46,7 @@ import { } from "@opentelemetry/instrumentation-restify" import { UndiciInstrumentation } from "@opentelemetry/instrumentation-undici" import { SpanProcessor, TestModeSpanProcessor } from "./span_processor" -import { Heartbeat } from "./heartbeat" +import { Cron } from "./check_in" const DefaultInstrumentations = { "@appsignal/opentelemetry-instrumentation-bullmq": BullMQInstrumentation, @@ -220,7 +220,7 @@ export class Client { await this.#sdk?.shutdown() this.metrics().probes().stop() this.extension.stop() - await Heartbeat.shutdown() + await Cron.shutdown() } /** diff --git a/src/heartbeat.ts b/src/heartbeat.ts index 59e3fd75..dca32450 100644 --- a/src/heartbeat.ts +++ b/src/heartbeat.ts @@ -1,115 +1,65 @@ -import crypto from "crypto" -import { Transmitter } from "./transmitter" import { Client } from "./client" +import { cron, Cron } from "./check_in" +export type { Event, EventKind } from "./check_in" -export type EventKind = "start" | "finish" - -export type Event = { - name: string - id: string - kind: EventKind - timestamp: number +type OnceFn = { + (): void + reset(): void } -class PendingPromiseSet extends Set> { - add(promise: Promise) { - super.add(promise) - promise.finally(() => this.delete(promise)) - return this - } - - async allSettled() { - await Promise.allSettled(this) - } -} - -export class Heartbeat { - private static heartbeatPromises = new PendingPromiseSet() - - name: string - id: string - - constructor(name: string) { - this.name = name - this.id = crypto.randomBytes(8).toString("hex") - } - - public static async shutdown() { - await Heartbeat.heartbeatPromises.allSettled() - } - - public start(): Promise { - return this.transmit(this.event("start")) - } - - public finish(): Promise { - return this.transmit(this.event("finish")) - } +function once void>( + fn: T, + ...args: Parameters +): OnceFn { + let done = false - private event(kind: EventKind): Event { - return { - name: this.name, - id: this.id, - kind: kind, - timestamp: Math.floor(Date.now() / 1000) + const onceFn = function () { + if (!done) { + fn(...args) + done = true } } - private transmit(event: Event): Promise { - if (Client.client === undefined || !Client.client.isActive) { - Client.internalLogger.debug( - "AppSignal not active, not transmitting heartbeat event" - ) - return Promise.resolve() - } - - const promise = new Transmitter( - `${Client.config.data.loggingEndpoint}/heartbeats/json`, - JSON.stringify(event) - ).transmit() - - const handledPromise = promise - .then(({ status }: { status: number }) => { - if (status >= 200 && status <= 299) { - Client.internalLogger.trace( - `Transmitted heartbeat \`${event.name}\` (${event.id}) ${event.kind} event` - ) - } else { - Client.internalLogger.warn( - `Failed to transmit heartbeat event: status code was ${status}` - ) - } - }) - .catch(({ error }: { error: Error }) => { - Client.internalLogger.warn( - `Failed to transmit heartbeat event: ${error.message}` - ) - - return Promise.resolve() - }) + onceFn.reset = () => { + done = false + } - Heartbeat.heartbeatPromises.add(handledPromise) + return onceFn +} - return handledPromise - } +function consoleAndLoggerWarn(message: string) { + console.warn(`appsignal WARNING: ${message}`) + Client.internalLogger.warn(message) } +export const heartbeatClassWarnOnce = once( + consoleAndLoggerWarn, + "The class `Heartbeat` has been deprecated. " + + "Please update uses of the class `new Heartbeat(...)` to `new checkIn.Cron(...)`, " + + 'importing it as `import { checkIn } from "@appsignal/nodejs"`, ' + + "in order to remove this message." +) + +export const heartbeatHelperWarnOnce = once( + consoleAndLoggerWarn, + "The helper `heartbeat` has been deprecated. " + + "Please update uses of the helper `heartbeat(...)` to `checkIn.cron(...)`, " + + 'importing it as `import { checkIn } from "@appsignal/nodejs"`, ' + + "in order to remove this message." +) + export function heartbeat(name: string): void export function heartbeat(name: string, fn: () => T): T export function heartbeat(name: string, fn?: () => T): T | undefined { - const heartbeat = new Heartbeat(name) - let output + heartbeatHelperWarnOnce() - if (fn) { - heartbeat.start() - output = fn() - } + return (cron as (name: string, fn?: () => T) => T | undefined)(name, fn) +} - if (output instanceof Promise) { - output.then(() => heartbeat.finish()).catch(() => {}) - } else { - heartbeat.finish() - } +export const Heartbeat = new Proxy(Cron, { + construct(target, args: ConstructorParameters) { + heartbeatClassWarnOnce() - return output -} + return new target(...args) + } +}) diff --git a/src/index.ts b/src/index.ts index 35deb414..8fb58daf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,4 +11,5 @@ export { expressErrorHandler } from "./instrumentation/express/error_handler" export { LoggerLevel, LoggerAttributes } from "./logger" export { WinstonTransport } from "./winston_transport" export * from "./helpers" +export * as checkIn from "./check_in" export { Heartbeat, heartbeat } from "./heartbeat"