Skip to content

feat(opentelemetry): create otel instrumentation for typed-express-router #1044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.pre-commit-config.yaml
.direnv/
.nyc_output
coverage/
dist/
node_modules/
2,760 changes: 2,413 additions & 347 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require: 'ts-node/register/transpile-only'
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# @api-ts/opentelemetry-instrumentation-typed-express-router

OpenTelemetry instrumentation for
[@api-ts/typed-express-router](https://github.com/BitGo/api-ts/tree/master/packages/typed-express-router).

This package provides instrumentation for the `typed-express-router` package by patching
the router creation functions to add telemetry middleware. The instrumentation focuses
on:

1. Creating decode spans for request validation operations
2. Creating encode spans for response serialization operations
3. Capturing validation errors and other relevant metadata in spans

## Installation

```bash
npm install --save @api-ts/opentelemetry-instrumentation-typed-express-router
# or
yarn add @api-ts/opentelemetry-instrumentation-typed-express-router
```

## Usage

Register the instrumentation with the OpenTelemetry SDK:

```typescript
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { TypedExpressRouterInstrumentation } from '@api-ts/opentelemetry-instrumentation-typed-express-router';

// Create and register the tracer provider
const provider = new NodeTracerProvider();
provider.register();

// Register the TypedExpressRouter instrumentation
registerInstrumentations({
instrumentations: [
new TypedExpressRouterInstrumentation({
// Optional: Configure instrumentation
}),
],
});

// Your application code using typed-express-router
import { createRouter } from '@api-ts/typed-express-router';
// ...
```

## How It Works

This instrumentation works by patching the `wrapRouter` and `createRouter` functions
from the `@api-ts/typed-express-router` package. When a router is created:

1. HTTP method handlers (get, post, put, etc.) are patched to inject tracing middleware
2. The middleware creates spans around decode and encode operations
3. Spans include relevant attributes like route name, HTTP method, and path
4. Validation errors are captured and added to span attributes

## Spans

This instrumentation creates the following spans:

### 1. Decode Span

Created when request validation occurs.

#### Name

By default: `typed-express-router.decode ${apiName}`

#### Attributes

| Attribute | Description |
| ------------------------- | ------------------------------------------------------ |
| `api_ts.route.name` | API name from the router spec |
| `api_ts.route.method` | HTTP method (get, post, put, etc.) |
| `api_ts.route.path` | Route path |
| `http.method` | HTTP method |
| `http.route` | HTTP route |
| `api_ts.validation_error` | Validation error messages (only when validation fails) |

### 2. Encode Span

Created when response serialization occurs.

#### Name

By default: `typed-express-router.encode ${apiName}`

#### Attributes

| Attribute | Description |
| --------------------- | ---------------------------------- |
| `api_ts.route.name` | API name from the router spec |
| `api_ts.route.method` | HTTP method (get, post, put, etc.) |
| `api_ts.route.path` | Route path |
| `http.method` | HTTP method |
| `http.route` | HTTP route |
| `http.status_code` | HTTP status code |

## Error Handling

The instrumentation tracks two main error scenarios:

1. **Validation Errors**: When request validation fails, the decode span is marked with
an ERROR status and includes validation error messages as attributes.

2. **Encode Errors**: When response serialization fails, the encode span is marked with
an ERROR status and includes error details.

Both error types are properly recorded for observability.

## License

Apache-2.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@api-ts/opentelemetry-instrumentation-typed-express-router",
"version": "0.0.0-semantically-released",
"description": "OpenTelemetry instrumentation for @api-ts/typed-express-router",
"license": "Apache-2.0",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"files": [
"dist/src/"
],
"scripts": {
"build": "tsc --build --incremental --verbose .",
"clean": "rm -rf -- dist",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"test": "nyc mocha 'test/**/*.test.ts'",
"test-a": "nyc mocha 'test/integration.test.ts'"
},
"dependencies": {
"@opentelemetry/instrumentation": "^0.48.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"shimmer": "^1.2.1"
},
"devDependencies": {
"@api-ts/typed-express-router": "^1.1.13",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/sdk-trace-base": "^1.20.0",
"@opentelemetry/sdk-trace-node": "^1.20.0",
"@types/express": "4.17.21",
"@types/mocha": "10.0.10",
"@types/node": "18.18.14",
"nyc": "17.1.0",
"express": "4.21.2",
"typescript": "4.7.4",
"ts-node": "10.9.2",
"mocha": "^10.7.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.7.0"
},
"engines": {
"node": ">=14.0.0"
},
"publishConfig": {
"access": "public"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* @api-ts/opentelemetry-instrumentation-typed-express-router
*/

export * from './instrumentation';
export * from './types';
export { ApiTsAttributes } from './utils';
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* @api-ts/opentelemetry-instrumentation-typed-express-router
*/

import {
InstrumentationBase,
InstrumentationNodeModuleDefinition,
isWrapped,
} from '@opentelemetry/instrumentation';
import { context, SpanKind } from '@opentelemetry/api';

import { TypedExpressRouterInstrumentationConfig } from './types';
const { version } = require('../package.json');
import {
createDefaultDecodeAttributes,
createDefaultEncodeAttributes,
handleGenericError,
} from './utils';
import { createRouter, wrapRouter } from '@api-ts/typed-express-router';
import express from 'express';

/**
* OpenTelemetry instrumentation for @api-ts/typed-express-router
*/
export class TypedExpressRouterInstrumentation extends InstrumentationBase<TypedExpressRouterInstrumentationConfig> {
constructor(config: TypedExpressRouterInstrumentationConfig = {}) {
super('opentelemetry-instrumentation-typed-express-router', version, config);
}

protected init() {
return [
new InstrumentationNodeModuleDefinition(
'@api-ts/typed-express-router',
['*'],
(moduleExports: any, moduleVersion) => {
if (!moduleExports) return;
this._diag.debug(`Patching @api-ts/typed-express-router@${moduleVersion}`);

if (isWrapped(moduleExports.wrapRouter)) {
this._unwrap(moduleExports, 'wrapRouter');
}
this._wrap(moduleExports, 'wrapRouter', this._getWrapRouterPatch());

if (isWrapped(moduleExports.createRouter)) {
this._unwrap(moduleExports, 'createRouter');
}
this._wrap(
moduleExports,
'createRouter',
this._getCreateRouterPatch(moduleExports.wrapRouter),
);

return moduleExports;
},
(moduleExports: any) => {
if (!moduleExports) return;
// Unpatch if needed
this._unwrap(moduleExports, 'wrapRouter');
this._unwrap(moduleExports, 'createRouter');

return moduleExports;
},
),
];
}

/**
* Patch the createRouter function
*/
private _getCreateRouterPatch(wrapRouterFn: typeof wrapRouter) {
console.log('patch create');
return function (original: typeof createRouter) {
return function createRouter(
this: unknown,
...args: Parameters<typeof original>
) {
const [spec, routerOptions = {}] = args;
const { onDecodeError, onEncodeError, afterEncodedResponseSent, ...options } =
routerOptions;
const router = express.Router(options);
return wrapRouterFn(router, spec, {
onDecodeError,
onEncodeError,
afterEncodedResponseSent,
});
};
};
}

/**
* Patch wrapRouter function
*/
private _getWrapRouterPatch() {
console.log('patch wrap');
const instrumentation = this;
return function (original: typeof wrapRouter) {
return function wrapRouter(this: unknown, ...args: Parameters<typeof original>) {
const [_, spec, __] = args;

Object.keys(spec).forEach((apiAction) => {
const methods = spec[apiAction];
if (!methods) return;
(['get', 'post', 'put', 'delete', 'patch'] as const).forEach((method) => {
const httpRoute = methods[method];
if (!httpRoute) return;

const originalDecode = httpRoute.request.decode;

httpRoute.request.decode = (val: unknown) => {
return instrumentation.tracer.startActiveSpan(
`typed-express-router.decode ${apiAction}`,
{ kind: SpanKind.INTERNAL },
context.active(),
(decodeSpan) => {
const decodeAttributes = createDefaultDecodeAttributes({
apiName: apiAction,
httpRoute,
});
decodeSpan.setAttributes(decodeAttributes);
try {
// Call original decode
return originalDecode.call(this, val);
} catch (error) {
// Record error
handleGenericError(decodeSpan, error);
throw error;
} finally {
decodeSpan.end();
}
},
);
};
});
});

// Call the original wrapRouter function
const wrappedRouter = original.apply(this, args);

// We need to specifically find and patch the middlewares that are added
// by typed-express-router when routes are added
(['get', 'post', 'put', 'delete', 'patch'] as const).forEach((method) => {
const handler = wrappedRouter[method];
if (handler) {
const originalMethod = handler;
wrappedRouter[method] = function (
this: any,
apiName: string,
handlers: any[],
options: any,
) {
// Create our middleware that will ensure spans are created
const traceMiddleware = function (req: any, res: any, next: Function) {
const originalSendEncoded = res.sendEncoded;
res.sendEncoded = function (this: any, status: any, payload: any) {
// Create encode span
instrumentation.tracer.startActiveSpan(
`typed-express-router.encode ${apiName}`,
{ kind: SpanKind.INTERNAL },
context.active(),
(encodeSpan) => {
const encodeAttributes = createDefaultEncodeAttributes(
status,
req,
);
encodeSpan.setAttributes(encodeAttributes);

try {
// Call original sendEncoded
return originalSendEncoded.call(this, status, payload);
} catch (error) {
// Record error
handleGenericError(encodeSpan, error);
throw error;
} finally {
encodeSpan.end();
}
},
);
};
next();
};

// Add our middleware to the beginning of the handler chain
const newHandlers = [traceMiddleware, ...handlers];

// Call the original method
return originalMethod.call(this, apiName, newHandlers, options);
};
}
});

return wrappedRouter;
};
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* @api-ts/opentelemetry-instrumentation-typed-express-router
*/

import { InstrumentationConfig } from '@opentelemetry/instrumentation';

/**
* TypedExpressRouter instrumentation options
*/
export interface TypedExpressRouterInstrumentationConfig
extends InstrumentationConfig {}
Loading
Loading