diff --git a/.changeset/quiet-dots-tease.md b/.changeset/quiet-dots-tease.md new file mode 100644 index 0000000..de154d9 --- /dev/null +++ b/.changeset/quiet-dots-tease.md @@ -0,0 +1,5 @@ +--- +"easy-apigateway": minor +--- + +Add support for Sentry diff --git a/package-lock.json b/package-lock.json index 75b9294..fd09083 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "easy-apigateway", - "version": "0.0.1", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "easy-apigateway", - "version": "0.0.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "etag": "^1.8.1", @@ -17,6 +17,7 @@ "devDependencies": { "@changesets/changelog-github": "^0.4.1", "@changesets/cli": "^2.18.0", + "@sentry/serverless": "^7.13.0", "@types/aws-lambda": "^8.10.84", "@types/etag": "^1.8.1", "@types/express": "^4.17.13", @@ -41,6 +42,7 @@ "yup": "^0.32.11" }, "optionalDependencies": { + "@sentry/serverless": ">= 7.13", "body-parser": ">= 1.19 < 2", "express": ">= 4.17 < 5", "yup": ">= 0.32.11" @@ -1726,6 +1728,109 @@ "node": ">=10.13.0" } }, + "node_modules/@sentry/core": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.13.0.tgz", + "integrity": "sha512-hB46fklmKrSDMEvZOF8qBHhys7PONBFyxQtbNDZUlv/kabs4gF3VEg1ftCaXnjx4lLNlsUl/ScFdM6194RvISg==", + "dev": true, + "dependencies": { + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/hub": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-7.13.0.tgz", + "integrity": "sha512-88/GsD1BoyrBwRKJCmVHZtSH5rizOsImUHWEXc1AOa1aR8nanfn56JdAbd6tC55pA+nT4R4H4vN/PrUaomTbtg==", + "dev": true, + "dependencies": { + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/node": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.13.0.tgz", + "integrity": "sha512-uP3bPAIRHPilnOEiYGQQDLaQphc/c7d87wm91bZrTJ+WPnMW4D/NmT7fna5zGGDQIr/KTdQ/LEpDeZOILbkCqQ==", + "dev": true, + "dependencies": { + "@sentry/core": "7.13.0", + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "cookie": "^0.4.1", + "https-proxy-agent": "^5.0.0", + "lru_map": "^0.3.3", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/serverless": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/serverless/-/serverless-7.13.0.tgz", + "integrity": "sha512-BxywieTpICAE/z3nGD7D0HlCWbQR+OMVKnuF55RJTkxE/bzYwzjyu8gWWWrxINTYZcIyslTGv5hZ9CGxgUyYtA==", + "dev": true, + "dependencies": { + "@sentry/node": "7.13.0", + "@sentry/tracing": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "@types/aws-lambda": "^8.10.62", + "@types/express": "^4.17.2", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/tracing": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.13.0.tgz", + "integrity": "sha512-/MKSd25rGv6Pc0FPBLXJifkfvSaYVPA8XUOLzVeDN0gl07h8AXli4qG9amTh/4Wb5h4dFpbcscOvW2VC+pxkIA==", + "dev": true, + "dependencies": { + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.13.0.tgz", + "integrity": "sha512-ttckM1XaeyHRLMdr79wmGA5PFbTGx2jio9DCD/mkEpSfk6OGfqfC7gpwy7BNstDH/VKyQj/lDCJPnwvWqARMoQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.13.0.tgz", + "integrity": "sha512-jnR85LgRLSk7IQe2OhKOPMY4fasJCNQNW0iCXsH+S2R1qnsF+N4ksNkQ+7JyyM9E7F03YpI2qd76bKY0VIn5iA==", + "dev": true, + "dependencies": { + "@sentry/types": "7.13.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -7369,6 +7474,12 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" }, + "node_modules/lru_map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", + "integrity": "sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==", + "dev": true + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -11899,6 +12010,88 @@ "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, + "@sentry/core": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.13.0.tgz", + "integrity": "sha512-hB46fklmKrSDMEvZOF8qBHhys7PONBFyxQtbNDZUlv/kabs4gF3VEg1ftCaXnjx4lLNlsUl/ScFdM6194RvISg==", + "dev": true, + "requires": { + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + } + }, + "@sentry/hub": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-7.13.0.tgz", + "integrity": "sha512-88/GsD1BoyrBwRKJCmVHZtSH5rizOsImUHWEXc1AOa1aR8nanfn56JdAbd6tC55pA+nT4R4H4vN/PrUaomTbtg==", + "dev": true, + "requires": { + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + } + }, + "@sentry/node": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.13.0.tgz", + "integrity": "sha512-uP3bPAIRHPilnOEiYGQQDLaQphc/c7d87wm91bZrTJ+WPnMW4D/NmT7fna5zGGDQIr/KTdQ/LEpDeZOILbkCqQ==", + "dev": true, + "requires": { + "@sentry/core": "7.13.0", + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "cookie": "^0.4.1", + "https-proxy-agent": "^5.0.0", + "lru_map": "^0.3.3", + "tslib": "^1.9.3" + } + }, + "@sentry/serverless": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/serverless/-/serverless-7.13.0.tgz", + "integrity": "sha512-BxywieTpICAE/z3nGD7D0HlCWbQR+OMVKnuF55RJTkxE/bzYwzjyu8gWWWrxINTYZcIyslTGv5hZ9CGxgUyYtA==", + "dev": true, + "requires": { + "@sentry/node": "7.13.0", + "@sentry/tracing": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "@types/aws-lambda": "^8.10.62", + "@types/express": "^4.17.2", + "tslib": "^1.9.3" + } + }, + "@sentry/tracing": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.13.0.tgz", + "integrity": "sha512-/MKSd25rGv6Pc0FPBLXJifkfvSaYVPA8XUOLzVeDN0gl07h8AXli4qG9amTh/4Wb5h4dFpbcscOvW2VC+pxkIA==", + "dev": true, + "requires": { + "@sentry/hub": "7.13.0", + "@sentry/types": "7.13.0", + "@sentry/utils": "7.13.0", + "tslib": "^1.9.3" + } + }, + "@sentry/types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.13.0.tgz", + "integrity": "sha512-ttckM1XaeyHRLMdr79wmGA5PFbTGx2jio9DCD/mkEpSfk6OGfqfC7gpwy7BNstDH/VKyQj/lDCJPnwvWqARMoQ==", + "dev": true + }, + "@sentry/utils": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.13.0.tgz", + "integrity": "sha512-jnR85LgRLSk7IQe2OhKOPMY4fasJCNQNW0iCXsH+S2R1qnsF+N4ksNkQ+7JyyM9E7F03YpI2qd76bKY0VIn5iA==", + "dev": true, + "requires": { + "@sentry/types": "7.13.0", + "tslib": "^1.9.3" + } + }, "@sinonjs/commons": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", @@ -16242,6 +16435,12 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.2.0.tgz", "integrity": "sha512-9RTUNjK60eJbx3uz+TEGF7fUr29ZDxR5QzXcyDpeSfeH28S9ycINflOgOlppit5U+4kNTe83KQnMEerw7GmE8w==" }, + "lru_map": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", + "integrity": "sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==", + "dev": true + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/package.json b/package.json index 92b06c3..0facbb1 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,13 @@ "express": ">= 4.17 < 5", "yup": ">= 0.32.11" }, + "peerDependencies": { + "@sentry/serverless": ">= 7.13" + }, "devDependencies": { "@changesets/changelog-github": "^0.4.1", "@changesets/cli": "^2.18.0", + "@sentry/serverless": "^7.13.0", "@types/aws-lambda": "^8.10.84", "@types/etag": "^1.8.1", "@types/express": "^4.17.13", diff --git a/src/framework.ts b/src/framework.ts index ebc0226..660e30b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12,6 +12,12 @@ import { defaultPermissionEvaluators, PermissionEvaluatorFactory, } from "./permissions"; +import { + captureErrorWithSentry, + createSentryWrapper, + setSentryUser, + type SentryWrapper, +} from "./sentry"; import type { AuthContext, AuthorizedLambdaHandler, @@ -26,9 +32,16 @@ import { createYupValidationErrorResponse } from "./yup"; * A framework to create lambda handlers for REST APIs. */ export class LambdaFramework { + private readonly sentryWrapper: SentryWrapper; private readonly permissionEvaluatorFactory: PermissionEvaluatorFactory; constructor(private readonly options: LambdaFrameworkOptions) { + this.sentryWrapper = createSentryWrapper( + options.sentry + ? { ...options.sentry, environment: options.stage } + : undefined + ); + this.permissionEvaluatorFactory = new PermissionEvaluatorFactory({ ...defaultPermissionEvaluators, ...options.permissionEvaluators, @@ -59,6 +72,7 @@ export class LambdaFramework { > { return this.unauthorized((event) => { const context = this.createAuthContext(event); + setSentryUser({ id: context.principalId }); return handler(event, context); }, lambdaOptions); } @@ -82,7 +96,7 @@ export class LambdaFramework { handler: HTTPLambdaHandler, lambdaOptions?: LambdaOptions ): HTTPLambdaHandler { - return async (event, ...args) => { + return this.sentryWrapper(async (event, ...args) => { try { const result = await handler(event, ...args); @@ -97,7 +111,7 @@ export class LambdaFramework { } catch (error) { return this.createErrorResponse(error, event); } - }; + }); } /** @@ -192,6 +206,7 @@ export class LambdaFramework { if (statusCode >= 500) { console.warn("Error in lambda handler:", error); + captureErrorWithSentry(error); } // TODO: can we somehow elegantly propagate properties from the error? diff --git a/src/options.ts b/src/options.ts index eaecffe..ebe67c1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -24,5 +24,17 @@ export function parseLambdaFrameworkOptionsFromEnv( return { stage: get("STAGE", "development"), + includeStackTrace: get("NODE_ENV", "development") === "development", + sentry: parseSentry(get), }; } + +function parseSentry(get: Get): LambdaFrameworkOptions["sentry"] { + const dsn = get("SENTRY_DSN", ""); + if (!dsn) return; + + const enabled = Boolean(get("SENTRY_ENABLED", "true")); + const tracesSampleRate = parseFloat(get("SENTRY_TRACES_SAMPLE_RATE", "0")); + + return { dsn, tracesSampleRate, enabled }; +} diff --git a/src/sentry.ts b/src/sentry.ts new file mode 100644 index 0000000..d5c08cd --- /dev/null +++ b/src/sentry.ts @@ -0,0 +1,31 @@ +import { AWSLambda, type User } from "@sentry/serverless"; +import type { Handler } from "aws-lambda"; + +export type SentryOptions = { + environment: string; + dsn: string; + enabled: boolean; + tracesSampleRate?: number; +}; + +export type SentryWrapper = ( + handler: Handler, + wrapOptions?: Partial +) => Handler; + +export function createSentryWrapper(options?: SentryOptions): SentryWrapper { + console.log("Sentry DSN:", options?.dsn); + if (!options?.dsn) return (handler) => handler; + + AWSLambda.init(options); + + return (handler, wrapOptions) => AWSLambda.wrapHandler(handler, wrapOptions); +} + +export function setSentryUser(user?: User): void { + AWSLambda.setUser(user ?? null); +} + +export function captureErrorWithSentry(err: unknown): void { + AWSLambda.captureException(err); +} diff --git a/src/types.ts b/src/types.ts index 5e99fe8..2234707 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ import type { import type { CacheOptions } from "./caching"; import type { CorsOptions } from "./cors"; import type { PermissionEvaluators, PermissionsEvaluator } from "./permissions"; +import type { SentryOptions } from "./sentry"; /** * The context returned from the API Gateway authorizer, @@ -34,6 +35,7 @@ export type LambdaFrameworkOptions = { permissionEvaluators?: PermissionEvaluators; includeStackTrace?: boolean; cors?: CorsOptions; + sentry?: Omit; }; export type HTTPLambdaHandler = Handler<