diff --git a/.changeset/friendly-dancers-reply.md b/.changeset/friendly-dancers-reply.md new file mode 100644 index 00000000..71fe038a --- /dev/null +++ b/.changeset/friendly-dancers-reply.md @@ -0,0 +1,5 @@ +--- +"@effect-aws/powertools-tracer": minor +--- + +Add captureLambdaHandler helper diff --git a/.changeset/nice-weeks-scream.md b/.changeset/nice-weeks-scream.md new file mode 100644 index 00000000..3ef671bc --- /dev/null +++ b/.changeset/nice-weeks-scream.md @@ -0,0 +1,5 @@ +--- +"@effect-aws/powertools-tracer": minor +--- + +Add captureAWSv3Client helper diff --git a/.projenrc.ts b/.projenrc.ts index b0c1f7c2..b438bd7a 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -130,7 +130,12 @@ new TypeScriptLibProject({ parent: project, name: "powertools-logger", description: "Effectful AWS Lambda Powertools Logger", - devDeps: [...effectDeps, "@aws-lambda-powertools/commons@2.0.0", "@aws-lambda-powertools/logger@2.0.0"], + devDeps: [ + ...effectDeps, + `${lambda.package.packageName}@workspace:^`, + "@aws-lambda-powertools/commons@2.0.0", + "@aws-lambda-powertools/logger@2.0.0", + ], peerDeps: [...commonPeerDeps, "@aws-lambda-powertools/logger@>=2.0.0"], }); @@ -141,7 +146,6 @@ const tracer = new TypeScriptLibProject({ deps: ["aws-xray-sdk-core@^3.5.3"], devDeps: [ ...effectDeps, - `${lambda.package.packageName}@workspace:^`, "@aws-lambda-powertools/commons@2.0.0", "@aws-lambda-powertools/tracer@2.0.0", "@types/aws-lambda", diff --git a/packages/powertools-logger/.projen/deps.json b/packages/powertools-logger/.projen/deps.json index d15e2276..0c34d2ab 100644 --- a/packages/powertools-logger/.projen/deps.json +++ b/packages/powertools-logger/.projen/deps.json @@ -10,6 +10,11 @@ "version": "2.0.0", "type": "build" }, + { + "name": "@effect-aws/lambda", + "version": "workspace:^", + "type": "build" + }, { "name": "@types/node", "version": "ts5.4", diff --git a/packages/powertools-logger/package.json b/packages/powertools-logger/package.json index 8bfba930..10e3e8fa 100644 --- a/packages/powertools-logger/package.json +++ b/packages/powertools-logger/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@aws-lambda-powertools/commons": "2.0.0", "@aws-lambda-powertools/logger": "2.0.0", + "@effect-aws/lambda": "workspace:^", "@types/node": "ts5.4", "effect": "^3.16.4", "typescript": "^5.4.2" diff --git a/packages/powertools-tracer/.projen/deps.json b/packages/powertools-tracer/.projen/deps.json index bd099ebe..3abbc4b4 100644 --- a/packages/powertools-tracer/.projen/deps.json +++ b/packages/powertools-tracer/.projen/deps.json @@ -10,11 +10,6 @@ "version": "2.0.0", "type": "build" }, - { - "name": "@effect-aws/lambda", - "version": "workspace:^", - "type": "build" - }, { "name": "@types/aws-lambda", "type": "build" diff --git a/packages/powertools-tracer/README.md b/packages/powertools-tracer/README.md index 6fea812a..99195d82 100644 --- a/packages/powertools-tracer/README.md +++ b/packages/powertools-tracer/README.md @@ -29,3 +29,120 @@ program.pipe(Effect.provide(Tracer.layer()), Effect.runSync) ``` Check out the a more complete example in the [examples](./examples/example.ts). + +## Lambda Handler Instrumentation + +Use `captureLambdaHandler` to automatically instrument your Lambda function with X-Ray tracing. This helper provides: + +- **Subsegment lifecycle management** - Creates a subsegment named `## ${_HANDLER}` and closes it automatically +- **Cold start annotation** - Annotates traces with cold start information +- **Service name annotation** - Annotates traces with the service name +- **Response capture** - Serializes function responses as metadata +- **Error capture** - Serializes errors as metadata + +```typescript +import { LambdaHandler } from "@effect-aws/lambda"; +import { Tracer } from "@effect-aws/powertools-tracer"; +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { Effect } from "effect"; + +const myEffectHandler = (event: APIGatewayProxyEventV2) => + Effect.gen(function* () { + yield* Effect.log("Processing request"); + return { statusCode: 200, body: JSON.stringify({ message: "Success" }) }; + }); + +export const handler = LambdaHandler.make({ + handler: Tracer.captureLambdaHandler()(myEffectHandler), + layer: Tracer.layerWithXrayTracer({ serviceName: "my-service" }), +}); +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `captureResponse` | `boolean` | `true` | Serialize function responses as metadata | + +To disable response capture: + +```typescript +export const handler = LambdaHandler.make({ + handler: Tracer.captureLambdaHandler({ captureResponse: false })(myEffectHandler), + layer: Tracer.layerWithXrayTracer(), +}); +``` + +## AWS SDK v3 Client Instrumentation + +Use `captureAWSv3Client` to automatically instrument AWS SDK v3 clients with X-Ray tracing. All AWS API calls made through instrumented clients will appear as subsegments in your traces. + +### S3 Example + +```typescript +import { S3ClientInstance, S3Service, makeS3Service } from "@effect-aws/client-s3"; +import { Tracer } from "@effect-aws/powertools-tracer"; +import { Layer } from "effect"; + +const InstrumentedS3ClientLayer = Layer.scoped( + S3ClientInstance.S3ClientInstance, + S3ClientInstance.make.pipe(Tracer.captureAWSv3Client), +); + +export const InstrumentedS3Layer = Layer.effect( + S3Service, + makeS3Service, +).pipe(Layer.provide(InstrumentedS3ClientLayer)); +``` + +### DynamoDB Document Example + +```typescript +import { DynamoDBClientInstance } from "@effect-aws/client-dynamodb"; +import { + DynamoDBDocumentClientInstance, + DynamoDBDocumentService, + makeDynamoDBDocumentService, +} from "@effect-aws/dynamodb"; +import { Tracer } from "@effect-aws/powertools-tracer"; +import { Layer } from "effect"; + +const InstrumentedDynamoDBClientLayer = Layer.scoped( + DynamoDBClientInstance.DynamoDBClientInstance, + DynamoDBClientInstance.make.pipe(Tracer.captureAWSv3Client), +); + +const InstrumentedDocumentClientLayer = Layer.scoped( + DynamoDBDocumentClientInstance.DynamoDBDocumentClientInstance, + DynamoDBDocumentClientInstance.make, +).pipe(Layer.provide(InstrumentedDynamoDBClientLayer)); + +export const InstrumentedDynamoDBDocumentLayer = Layer.effect( + DynamoDBDocumentService, + makeDynamoDBDocumentService, +).pipe(Layer.provide(InstrumentedDocumentClientLayer)); +``` + +> **Note:** Instrumented layers require `XrayTracer` in the environment. Use `layerWithXrayTracer` when composing your final application layer. + +## Available Layers + +| Layer | Description | +|-------|-------------| +| `layer(options?)` | Sets Effect's tracer (most common) | +| `layerWithXrayTracer(options?)` | Provides `XrayTracer` service AND sets Effect's tracer | +| `layerTracer(options?)` | Only provides `XrayTracer` service | +| `layerWithoutXrayTracer` | Only sets Effect's tracer (requires `XrayTracer` from context) | + +Use `layerWithXrayTracer` when you need to use `captureLambdaHandler`, as it requires the `XrayTracer` service. + +## Prerequisites + +To use X-Ray tracing with AWS Lambda: + +1. **Enable Active Tracing** on your Lambda function (AWS Console / SAM / CDK) +2. **IAM permissions**: `xray:PutTraceSegments`, `xray:PutTelemetryRecords` +3. **Environment variables** (optional): + - `POWERTOOLS_SERVICE_NAME` - Service name for traces + - `POWERTOOLS_TRACER_CAPTURE_RESPONSE` - Enable/disable response capture + - `POWERTOOLS_TRACER_CAPTURE_ERROR` - Enable/disable error capture diff --git a/packages/powertools-tracer/examples/example.ts b/packages/powertools-tracer/examples/example.ts index ba89bdb0..d43c4418 100644 --- a/packages/powertools-tracer/examples/example.ts +++ b/packages/powertools-tracer/examples/example.ts @@ -1,4 +1,4 @@ -import { makeLambda } from "@effect-aws/lambda"; +import { LambdaHandler } from "@effect-aws/lambda"; import { Tracer } from "@effect-aws/powertools-tracer"; import type { APIGatewayProxyEventV2, APIGatewayProxyHandlerV2 } from "aws-lambda"; import { Effect, flow } from "effect"; @@ -53,22 +53,24 @@ class BadInputError { readonly _tag = "BadInputError"; } -export const handler: APIGatewayProxyHandlerV2 = makeLambda({ - handler: flow( - program, - Effect.catchTags({ - BadInputError: () => +export const handler: APIGatewayProxyHandlerV2 = LambdaHandler.make({ + handler: Tracer.captureLambdaHandler()( + flow( + program, + Effect.catchTags({ + BadInputError: () => + Effect.succeed({ + statusCode: 400, + body: JSON.stringify({ message: "Bad input" }), + }), + }), + Effect.catchAllDefect((defect) => Effect.succeed({ - statusCode: 400, - body: JSON.stringify({ message: "Bad input" }), - }), - }), - Effect.catchAllDefect((defect) => - Effect.succeed({ - statusCode: 500, - body: JSON.stringify({ message: `An error occurred: ${defect}` }), - }) + statusCode: 500, + body: JSON.stringify({ message: `An error occurred: ${defect}` }), + }) + ), ), ), - layer: Tracer.layer(), + layer: Tracer.layerWithXrayTracer(), }); diff --git a/packages/powertools-tracer/package.json b/packages/powertools-tracer/package.json index c4a0889d..2af0a878 100644 --- a/packages/powertools-tracer/package.json +++ b/packages/powertools-tracer/package.json @@ -27,7 +27,6 @@ "devDependencies": { "@aws-lambda-powertools/commons": "2.0.0", "@aws-lambda-powertools/tracer": "2.0.0", - "@effect-aws/lambda": "workspace:^", "@types/aws-lambda": "^8.10.157", "@types/node": "ts5.4", "effect": "^3.16.4", diff --git a/packages/powertools-tracer/src/Tracer.ts b/packages/powertools-tracer/src/Tracer.ts index 91c515e5..e1ecd9eb 100644 --- a/packages/powertools-tracer/src/Tracer.ts +++ b/packages/powertools-tracer/src/Tracer.ts @@ -1,18 +1,29 @@ /** * @since 1.0.0 */ -import type { TracerInterface, TracerOptions } from "@aws-lambda-powertools/tracer/types"; +import type { CaptureLambdaHandlerOptions, TracerInterface, TracerOptions } from "@aws-lambda-powertools/tracer/types"; +import type { Context } from "aws-lambda"; +import type { ConfigError, Effect, Tracer } from "effect"; import type { Tag } from "effect/Context"; -import type { Effect } from "effect/Effect"; import type { Layer } from "effect/Layer"; -import type { Tracer as EffectTracer } from "effect/Tracer"; import * as internal from "./internal/tracer.js"; +/** + * Effectful AWS Lambda handler type. + * + * @since 1.0.0 + * @category model + */ +export type EffectHandler = ( + event: T, + context: Context, +) => Effect.Effect; + /** * @since 1.0.0 * @category constructors */ -export const make: Effect = internal.make; +export const make: Effect.Effect = internal.make; /** * @since 1.0.0 @@ -45,3 +56,41 @@ export const layerWithoutXrayTracer: Layer = internal. * @category layers */ export const layer: (options?: TracerOptions) => Layer = internal.layer; + +/** + * @since 1.0.0 + * @category layers + */ +export const layerWithXrayTracer: ( + options?: TracerOptions, +) => Layer = internal.layerWithXrayTracer; + +/** + * Wraps an Effect handler with X-Ray tracing instrumentation. + * + * Automatically: + * - Creates subsegment for the Lambda handler + * - Annotates cold start and service name + * - Captures response/errors as metadata + * + * @since 1.0.0 + * @category tracing + */ +export const captureLambdaHandler: ( + options?: CaptureLambdaHandlerOptions | undefined, +) => ( + handler: EffectHandler, +) => EffectHandler = internal.captureLambdaHandler; + +/** + * Instruments an AWS SDK v3 client Effect with X-Ray tracing. + * + * Use with Layer.scoped to create instrumented client layers that + * automatically capture AWS API calls in X-Ray traces. + * + * @since 1.0.0 + * @category tracing + */ +export const captureAWSv3Client: ( + self: Effect.Effect, +) => Effect.Effect = internal.captureAWSv3Client; diff --git a/packages/powertools-tracer/src/internal/tracer.ts b/packages/powertools-tracer/src/internal/tracer.ts index 9cc1f4f5..997d0a65 100644 --- a/packages/powertools-tracer/src/internal/tracer.ts +++ b/packages/powertools-tracer/src/internal/tracer.ts @@ -1,13 +1,17 @@ import * as PowerTools from "@aws-lambda-powertools/tracer"; -import type { TracerInterface, TracerOptions } from "@aws-lambda-powertools/tracer/types"; +import type { CaptureLambdaHandlerOptions, TracerInterface, TracerOptions } from "@aws-lambda-powertools/tracer/types"; +import type { EffectHandler } from "@effect-aws/lambda"; import * as Xray from "aws-xray-sdk-core"; -import { Cause, Context, Effect, Layer, Option } from "effect"; +import type { ConfigError } from "effect"; +import { Cause, Config, Context, Effect, Layer, Option } from "effect"; import type { Exit } from "effect/Exit"; import * as EffectTracer from "effect/Tracer"; -import type { XrayTracer } from "../Tracer.js"; +import { XrayTracer } from "../Tracer.js"; import { unknownToAttributeValue } from "./utils.js"; -const XraySpanTypeId = Symbol.for("@effect-aws/powertools-tracer/Tracer/XraySpan"); +const XraySpanTypeId = Symbol.for( + "@effect-aws/powertools-tracer/Tracer/XraySpan", +); type XraySegment = NonNullable>; @@ -40,7 +44,9 @@ export class XraySpan implements EffectTracer.Span { parentSegment = span; } - this.span = parentSegment ? parentSegment.addNewSubsegment(name) : new Xray.Segment(name); + this.span = parentSegment + ? parentSegment.addNewSubsegment(name) + : new Xray.Segment(name); this.spanId = this.span.id; this.traceId = tracer.getRootXrayTraceId() ?? (this.span as Xray.Segment).trace_id; this.status = { @@ -95,7 +101,11 @@ export class XraySpan implements EffectTracer.Span { this.span.close(); } - event(name: string, _startTime: bigint, attributes?: Record) { + event( + name: string, + _startTime: bigint, + attributes?: Record, + ) { this.span.addMetadata(name, attributes); } } @@ -128,7 +138,81 @@ export const make = Effect.map(Tracer, (tracer) => export const layerTracer = (options?: TracerOptions) => Layer.sync(Tracer, () => new PowerTools.Tracer(options)); /** @internal */ -export const layerWithoutXrayTracer = Layer.unwrapEffect(Effect.map(make, Layer.setTracer)); +export const layerWithoutXrayTracer = Layer.unwrapEffect( + Effect.map(make, Layer.setTracer), +); /** @internal */ export const layer = (options?: TracerOptions) => layerWithoutXrayTracer.pipe(Layer.provide(layerTracer(options))); + +/** @internal */ +export const layerWithXrayTracer = (options?: TracerOptions) => + layerWithoutXrayTracer.pipe(Layer.provideMerge(layerTracer(options))); + +/** @internal + * @link https://docs.aws.amazon.com/powertools/typescript/latest/features/tracer/#lambda-handler + */ +export const captureLambdaHandler = (options?: CaptureLambdaHandlerOptions | undefined) => +( + handler: EffectHandler, +): EffectHandler => +(event, context) => + Effect.gen(function*() { + const tracer = yield* XrayTracer; + const _HANDLER = yield* Config.string("_HANDLER"); + + const segment = tracer.getSegment(); // This is the facade segment (the one that is created by AWS Lambda) + let subsegment: Xray.Subsegment | undefined; + if (segment) { + // Create subsegment for the function & set it as active + subsegment = segment.addNewSubsegment(`## ${_HANDLER}`); + tracer.setSegment(subsegment); + } + + // Annotate the subsegment with the cold start & serviceName + tracer.annotateColdStart(); + tracer.addServiceNameAnnotation(); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (segment && subsegment) { + // Close subsegment (the AWS Lambda one is closed automatically) + subsegment.close(); + // Set back the facade segment as active again + tracer.setSegment(segment); + } + }) + ); + + return yield* handler(event, context).pipe( + Effect.tap((result) => + Effect.sync(() => { + // Add the response as metadata + if (options?.captureResponse ?? true) { + tracer.addResponseAsMetadata(result, _HANDLER); + } + }) + ), + Effect.tapError((error) => + Effect.sync(() => { + // Add the error as metadata + tracer.addErrorAsMetadata(error as Error); + }) + ), + ); + }).pipe(Effect.scoped); + +/** @internal + * @link https://docs.powertools.aws.dev/lambda/typescript/latest/core/tracer/#tracing-aws-sdk-v3-clients + */ +export const captureAWSv3Client = ( + self: Effect.Effect, +): Effect.Effect => + self.pipe( + Effect.flatMap((client) => + Effect.gen(function*() { + const tracer = yield* XrayTracer; + return tracer.captureAWSv3Client(client) ?? client; + }) + ), + ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86e214bb..39a1d5c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1417,6 +1417,9 @@ importers: '@aws-lambda-powertools/logger': specifier: 2.0.0 version: 2.0.0 + '@effect-aws/lambda': + specifier: workspace:^ + version: link:../lambda/dist '@types/node': specifier: ts5.4 version: 24.10.1 @@ -1440,9 +1443,6 @@ importers: '@aws-lambda-powertools/tracer': specifier: 2.0.0 version: 2.0.0 - '@effect-aws/lambda': - specifier: workspace:^ - version: link:../lambda/dist '@types/aws-lambda': specifier: ^8.10.157 version: 8.10.157