diff --git a/packages/modules/edge-api-sdk/package.json b/packages/modules/edge-api-sdk/package.json index c00ce8a..d4ed561 100644 --- a/packages/modules/edge-api-sdk/package.json +++ b/packages/modules/edge-api-sdk/package.json @@ -1,8 +1,8 @@ { "name": "@reapit-cdk/edge-api-sdk", - "main": "src/index.ts", + "main": "dist/index.js", "description": "Provides convenience wrappers for accepting and responding to [@reapit-cdk/edge-api]('../../constructs/edge-api/readme.md') lambda requests.", - "version": "0.0.0", + "version": "0.0.9", "homepage": "https://github.com/reapit/ts-cdk-constructs/blob/main/packages/modules/edge-api-sdk", "readme": "https://github.com/reapit/ts-cdk-constructs/blob/main/packages/modules/edge-api-sdk/readme.md", "bugs": { @@ -13,9 +13,6 @@ "url": "https://github.com/reapit/ts-cdk-constructs.git" }, "license": "MIT", - "publishConfig": { - "main": "dist/index.js" - }, "scripts": { "publish": "yarn npm publish", "check": "yarn run root:check -p $(pwd)", @@ -29,5 +26,10 @@ "@reapit-cdk/tsup": "workspace:^", "@types/aws-lambda": "^8.10.121", "aws-lambda": "^1.0.7" + }, + "dependencies": { + "@sentry/node": "^7.109.0", + "@sentry/types": "^7.109.0", + "@sentry/utils": "^7.109.0" } } diff --git a/packages/modules/edge-api-sdk/src/edge-api-sdk.ts b/packages/modules/edge-api-sdk/src/edge-api-sdk.ts index 0adbb79..38ea8cf 100644 --- a/packages/modules/edge-api-sdk/src/edge-api-sdk.ts +++ b/packages/modules/edge-api-sdk/src/edge-api-sdk.ts @@ -3,7 +3,7 @@ import * as cloudfront from './cloudfront' import * as httpApi from './http-api' import { EventInput, JSONRequestHandler, RCHeaders, RCRequest, RCResponse, RequestHandler } from './types' -import { createLogger, panic } from './logger' +import { Logger, LoggerConfig, panic } from './logger' import { sessionIdHeaderName } from './config' const eventToRequest = (event: EventInput, context: Context): RCRequest => { @@ -57,12 +57,17 @@ const respondToEvent = (event: EventInput, response: RCResponse) => { } } -export const requestHandler = (requestHandler: RequestHandler) => { +export type HandlerConfig = { + loggerConfig: LoggerConfig +} + +export const requestHandler = (requestHandler: RequestHandler, handlerConfig: HandlerConfig) => { const fn = async (event: EventInput, conext: Context) => { try { const request = eventToRequest(event, conext) - const logger = createLogger(request) + const logger = new Logger(request, handlerConfig?.loggerConfig) if (request.method === 'OPTIONS') { + await logger.flush() return respondToEvent(event, { status: 200, headers: {}, @@ -70,6 +75,7 @@ export const requestHandler = (requestHandler: RequestHandler) } try { const response = await requestHandler({ ...request, logger }) + await logger.flush() return respondToEvent(event, response) } catch (e) { logger.error(e) @@ -103,13 +109,15 @@ const errorResponseToEvent = (event: EventInput, err: Error) => { export const jsonRequestHandler = ( jsonRequestHandler: JSONRequestHandler, + handlerConfig?: HandlerConfig, ) => { const fn = async (event: EventInput, context: Context) => { try { const request = eventToRequest(event, context) - const logger = createLogger(request) + const logger = new Logger(request, handlerConfig?.loggerConfig) const body = request.body ? JSON.parse(request.body) : undefined if (request.method === 'OPTIONS') { + await logger.flush() return respondToEvent(event, { status: 200, headers: {}, @@ -122,6 +130,7 @@ export const jsonRequestHandler = ( body: body as BodyType | undefined, logger, }) + await logger.flush() if (response.status === 302) { return respondToEvent(event, { @@ -157,13 +166,15 @@ export const jsonRequestHandler = ( export const formRequestHandler = ( formRequestHandler: JSONRequestHandler, + handlerConfig?: HandlerConfig, ) => { const fn = async (event: EventInput, context: Context) => { try { const request = eventToRequest(event, context) - const logger = createLogger(request) + const logger = new Logger(request, handlerConfig?.loggerConfig) const body = request.body ? Object.fromEntries(new URLSearchParams(request.body)) : undefined if (request.method === 'OPTIONS') { + await logger.flush() return respondToEvent(event, { status: 200, headers: {}, @@ -176,6 +187,7 @@ export const formRequestHandler = ( body: body as BodyType | undefined, logger, }) + await logger.flush() if (response.status === 302) { return respondToEvent(event, { diff --git a/packages/modules/edge-api-sdk/src/index.ts b/packages/modules/edge-api-sdk/src/index.ts index a130093..1b9b630 100644 --- a/packages/modules/edge-api-sdk/src/index.ts +++ b/packages/modules/edge-api-sdk/src/index.ts @@ -1,3 +1,4 @@ export * from './edge-api-sdk' export * from './types' export * from './config' +export * from './sentry' diff --git a/packages/modules/edge-api-sdk/src/logger.ts b/packages/modules/edge-api-sdk/src/logger.ts index a5d91d3..16b48a7 100644 --- a/packages/modules/edge-api-sdk/src/logger.ts +++ b/packages/modules/edge-api-sdk/src/logger.ts @@ -1,20 +1,16 @@ import { EventInput, JSONRequest, RCRequest } from './types' import { format } from 'util' -type LogFn = (message?: any, ...optionalParams: any[]) => void +export type LogLevel = 'info' | 'warning' | 'error' | 'critical' | 'panic' -type LogLevel = 'info' | 'warning' | 'error' | 'critical' | 'panic' - -export type Logger = Record - -type LogEntry = { +export type LogEntry = { level: LogLevel message: string timestamp: Date error?: Error } -type MinimalLogPayload = { +export type MinimalLogPayload = { event: EventInput functionName: string functionVersion: string @@ -73,30 +69,10 @@ const scrub = (payload: T): T => { } } -const writeOut = (payload: MinimalLogPayload | LogPayload) => { - console.log(JSON.stringify(scrub(payload))) -} - -const flush = (request: RCRequest, entries: LogEntry[], centralize: boolean) => { - const { - meta: { functionName, functionVersion, invocationId, sessionId, event }, - region, - } = request - - const payload: LogPayload = { - entries, - event, - functionName, - functionVersion, - invocationId, - sessionId, - region, - request, - centralize, - timestamp: new Date(), - } +export type Transport = (payload: MinimalLogPayload | LogPayload) => void | Promise - writeOut(payload) +export const consoleTransport = (payload: MinimalLogPayload | LogPayload) => { + console.log(JSON.stringify(scrub(payload))) } type AdditionalLogData = { @@ -128,7 +104,7 @@ export const panic = (error: Error, event: EventInput) => { }, ], } - writeOut(payload) + consoleTransport(payload) } const stringifiableError = (error?: Error): Error | undefined => { @@ -142,12 +118,18 @@ const stringifiableError = (error?: Error): Error | undefined => { } } -export const createLogger = (request: RCRequest): Logger => { - const entries: LogEntry[] = [] +export type LoggerConfig = { + transports: Transport[] +} - const logFn = - (level: LogLevel): LogFn => - (...args) => { +export class Logger { + private request: RCRequest + private entries: LogEntry[] = [] + private transports: Transport[] + private queue: (void | Promise)[] = [] + + private logFn(level: LogLevel) { + return (...args: any[]) => { if (level === 'warning') { console.warn(...args) } else if (level === 'critical' || level === 'error' || level === 'panic') { @@ -157,23 +139,64 @@ export const createLogger = (request: RCRequest): Logger => { } else { console.log(...args) } - entries.push({ + this.entries.push({ level, message: format(...args), timestamp: new Date(), error: stringifiableError(args.find(isAdditionalLogData)?.error), }) - const centralize = level === 'error' || level === 'critical' + const centralize = level === 'error' || level === 'critical' || level === 'panic' if (centralize) { - flush(request, entries, centralize) + this.writeOut(centralize) } } + } - return { - info: logFn('info'), - warning: logFn('warning'), - error: logFn('error'), - critical: logFn('critical'), - panic: logFn('panic'), + private writeOut(centralize: boolean) { + const { + meta: { functionName, functionVersion, invocationId, sessionId, event }, + region, + } = this.request + + const payload: LogPayload = { + entries: this.entries, + event, + functionName, + functionVersion, + invocationId, + sessionId, + region, + request: this.request, + centralize, + timestamp: new Date(), + } + + this.queue.push(...this.transports.map((transport) => transport(payload))) + } + + info(...args: any[]) { + this.logFn('info')(...args) + } + + warning(...args: any[]) { + this.logFn('warning')(...args) + } + error(...args: any[]) { + this.logFn('error')(...args) + } + critical(...args: any[]) { + this.logFn('critical')(...args) + } + panic(...args: any[]) { + this.logFn('panic')(...args) + } + + async flush() { + await Promise.all(this.queue) + } + + constructor(request: RCRequest, config?: LoggerConfig) { + this.request = request + this.transports = config?.transports || [consoleTransport] } } diff --git a/packages/modules/edge-api-sdk/src/sentry.ts b/packages/modules/edge-api-sdk/src/sentry.ts new file mode 100644 index 0000000..cabff15 --- /dev/null +++ b/packages/modules/edge-api-sdk/src/sentry.ts @@ -0,0 +1,59 @@ +import { NodeClient, createTransport, createGetModuleFromFilename } from '@sentry/node' +import { NodeTransportOptions } from '@sentry/node/types/transports' +import { Transport as SentryTransport, SeverityLevel } from '@sentry/types' +import { stackParserFromStackParserOptions, createStackParser, nodeStackLineParser } from '@sentry/utils' +import { LogLevel, LogPayload, MinimalLogPayload, Transport } from './logger' + +const sentryTransport: (transportOptions: NodeTransportOptions) => SentryTransport = (options) => { + return createTransport(options, async (request) => { + const response = await fetch(options.url, { + body: request.body, + method: 'POST', + referrerPolicy: 'origin', + headers: options.headers, + }) + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + } + }) +} + +const stackParser = stackParserFromStackParserOptions( + createStackParser(nodeStackLineParser(createGetModuleFromFilename())), +) + +export type InitProps = { + dsn: string + environment?: string + release?: string +} + +const logLevelToSeverityLevel = (logLevel: LogLevel): SeverityLevel => { + if (logLevel === 'panic' || logLevel === 'critical') { + return 'fatal' + } + return logLevel +} + +export const init = (props: InitProps): Transport => { + const client = new NodeClient({ + integrations: [], + transport: sentryTransport, + stackParser, + ...props, + }) + + return async (payload: MinimalLogPayload | LogPayload) => { + payload.entries.forEach((entry) => { + client.captureMessage(entry.message, logLevelToSeverityLevel(entry.level)) + if (entry.error) { + client.captureException(entry.error) + } + }) + await client.flush() + } +} diff --git a/packages/modules/edge-api-sdk/tests/logger.test.ts b/packages/modules/edge-api-sdk/tests/logger.test.ts index 42db697..f635826 100644 --- a/packages/modules/edge-api-sdk/tests/logger.test.ts +++ b/packages/modules/edge-api-sdk/tests/logger.test.ts @@ -1,6 +1,6 @@ import { CloudFrontRequestEvent } from 'aws-lambda' import { JSONRequest } from '../src' -import { LogPayload, createLogger, panic } from '../src/logger' +import { LogPayload, Logger, LoggerConfig, panic } from '../src/logger' const generateCloudfrontRequest = ({ headers = { @@ -56,7 +56,7 @@ const generateCloudfrontRequest = ({ } } -const getLogger = (body: any = { password: 'user-password' }) => { +const getLogger = (body: any = { password: 'user-password' }, config?: LoggerConfig) => { const request: JSONRequest = { env: {}, headers: { @@ -76,7 +76,7 @@ const getLogger = (body: any = { password: 'user-password' }) => { sessionId: 'session-id', }, } - const logger = createLogger(request) + const logger = new Logger(request, config) return logger } @@ -207,4 +207,24 @@ describe('logger', () => { expect(format.entries[0].error?.name).toBe('Big Error') expect(format.entries[0].level).toBe('panic') }) + + it('transports - should be called', () => { + const transportMock = jest.fn() + const logger = getLogger(undefined, { + transports: [transportMock], + }) + logger.info('something') + logger.info('something again') + logger.critical('oh no') + expect(transportMock).toHaveBeenCalledTimes(1) + const entries = transportMock.mock.calls[0][0].entries + expect(entries[0].level).toEqual('info') + expect(entries[0].message).toEqual('something') + + expect(entries[1].level).toEqual('info') + expect(entries[1].message).toEqual('something again') + + expect(entries[2].level).toEqual('critical') + expect(entries[2].message).toEqual('oh no') + }) }) diff --git a/yarn.lock b/yarn.lock index ca43d29..29344d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3628,6 +3628,9 @@ __metadata: dependencies: "@reapit-cdk/eslint-config": "workspace:^" "@reapit-cdk/tsup": "workspace:^" + "@sentry/node": "npm:^7.109.0" + "@sentry/types": "npm:^7.109.0" + "@sentry/utils": "npm:^7.109.0" "@types/aws-lambda": "npm:^8.10.121" aws-lambda: "npm:^1.0.7" languageName: unknown @@ -3977,6 +3980,55 @@ __metadata: languageName: node linkType: hard +"@sentry-internal/tracing@npm:7.109.0": + version: 7.109.0 + resolution: "@sentry-internal/tracing@npm:7.109.0" + dependencies: + "@sentry/core": "npm:7.109.0" + "@sentry/types": "npm:7.109.0" + "@sentry/utils": "npm:7.109.0" + checksum: b1cef742559d966e425a133fc3456dc6e2c874b8a496ef1e49914262b4bccbfb4487a5b02f17d285ac77f5c50dbe84ec85330ae672df4685816e20163e4ffb6d + languageName: node + linkType: hard + +"@sentry/core@npm:7.109.0": + version: 7.109.0 + resolution: "@sentry/core@npm:7.109.0" + dependencies: + "@sentry/types": "npm:7.109.0" + "@sentry/utils": "npm:7.109.0" + checksum: 62026e03e25b4a917e8b35f901406265995f31f54ddfa486b386a73c743fb54d52b420d78904e3efb0a94f8bde71254187f44d26bee273a9e1c190ab95b81e59 + languageName: node + linkType: hard + +"@sentry/node@npm:^7.109.0": + version: 7.109.0 + resolution: "@sentry/node@npm:7.109.0" + dependencies: + "@sentry-internal/tracing": "npm:7.109.0" + "@sentry/core": "npm:7.109.0" + "@sentry/types": "npm:7.109.0" + "@sentry/utils": "npm:7.109.0" + checksum: f441f652db044b65e2e29c879bc668adc911587e61d22725664623a74312f0935b647b66cf42bf953be7411826f30874b2c69a09f99149f76fb65304915c38f7 + languageName: node + linkType: hard + +"@sentry/types@npm:7.109.0, @sentry/types@npm:^7.109.0": + version: 7.109.0 + resolution: "@sentry/types@npm:7.109.0" + checksum: b434c28b0dd34e76a5a47bd9d9782f873b93a42f452d0562145cd0b2b6da1e1d2856108f26597c825009efa8594233916b7b2bda6f5b183dfe621eaf3519e140 + languageName: node + linkType: hard + +"@sentry/utils@npm:7.109.0, @sentry/utils@npm:^7.109.0": + version: 7.109.0 + resolution: "@sentry/utils@npm:7.109.0" + dependencies: + "@sentry/types": "npm:7.109.0" + checksum: 127f7074e1665f5097a63aa85244040842553bc08b2d06e06d55f096682d609e9add3e2da22d09cd57bb99093b48727f237c3389b5c3fb71f36b501d20375002 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.51 resolution: "@sinclair/typebox@npm:0.24.51"