diff --git a/.github/workflows/ci-otel.yml b/.github/workflows/ci-otel.yml new file mode 100644 index 00000000..fae19ce6 --- /dev/null +++ b/.github/workflows/ci-otel.yml @@ -0,0 +1,25 @@ +name: ci-otel +on: + push: + branches: [main] + paths: + - 'packages/otel/**' + pull_request: + branches: ['*'] + paths: + - 'packages/otel/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/otel + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - run: yarn install --frozen-lockfile + - run: yarn build + - run: yarn test diff --git a/package.json b/package.json index a3e75b17..9935a083 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "build:ajv-validator": "yarn workspace @hono/ajv-validator build", "build:tsyringe": "yarn workspace @hono/tsyringe build", "build:cloudflare-access": "yarn workspace @hono/cloudflare-access build", + "build:otel": "yarn workspace @hono/otel build", "build": "run-p 'build:*'", "lint": "eslint 'packages/**/*.{ts,tsx}'", "lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'", diff --git a/packages/otel/CHANGELOG.md b/packages/otel/CHANGELOG.md new file mode 100644 index 00000000..637330e4 --- /dev/null +++ b/packages/otel/CHANGELOG.md @@ -0,0 +1 @@ +# @hono/otel diff --git a/packages/otel/README.md b/packages/otel/README.md new file mode 100644 index 00000000..c182f4ce --- /dev/null +++ b/packages/otel/README.md @@ -0,0 +1,37 @@ +# OpenTelemetry middleware for Hono + +This package provides a [Hono](https://hono.dev/) middleware that instruments your application with [OpenTelemetry](https://opentelemetry.io/). + +## Usage + +```ts +import { otel } from '@hono/otel' +import { NodeSDK } from '@opentelemetry/sdk-node' +import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node' +import { Hono } from 'hono' + +const sdk = new NodeSDK({ + traceExporter: new ConsoleSpanExporter(), +}) + +sdk.start() + +const app = new Hono() + +app.use('*', otel()) +app.get('/', (c) => c.text('foo')) + +export default app +``` + +## Limitation + +Since this instrumentation is based on Hono's middleware system, it instruments the entire request-response lifecycle. This means that it doesn't provide fine-grained instrumentation for individual middleware. + +## Author + +Hong Minhee + +## License + +MIT diff --git a/packages/otel/package.json b/packages/otel/package.json new file mode 100644 index 00000000..1e75761b --- /dev/null +++ b/packages/otel/package.json @@ -0,0 +1,53 @@ +{ + "name": "@hono/otel", + "version": "0.1.0", + "description": "OpenTelemetry middleware for Hono", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "publint": "publint", + "release": "yarn build && yarn test && yarn publint && yarn publish" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/honojs/middleware.git" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": "*" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/semantic-conventions": "^1.28.0" + }, + "devDependencies": { + "@opentelemetry/sdk-trace-base": "^1.30.0", + "@opentelemetry/sdk-trace-node": "^1.30.0", + "hono": "^4.4.12", + "tsup": "^8.1.0", + "vitest": "^1.6.0" + } +} diff --git a/packages/otel/src/index.test.ts b/packages/otel/src/index.test.ts new file mode 100644 index 00000000..de12dd39 --- /dev/null +++ b/packages/otel/src/index.test.ts @@ -0,0 +1,62 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' +import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_HEADER, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_URL_FULL, + ATTR_HTTP_ROUTE, +} from '@opentelemetry/semantic-conventions' +import { Hono } from 'hono' +import { otel } from '../src' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' + +describe('OpenTelemetry middleware', () => { + const app = new Hono() + + const memoryExporter = new InMemorySpanExporter() + const spanProcessor = new SimpleSpanProcessor(memoryExporter) + const tracerProvider = new NodeTracerProvider({ + spanProcessors: [spanProcessor], + }) + + app.use(otel({ tracerProvider })) + app.get('/foo', (c) => c.text('foo')) + app.post('/error', (_) => { + throw new Error('error message') + }) + + it('Should make a span', async () => { + memoryExporter.reset() + const response = await app.request('http://localhost/foo') + const spans = memoryExporter.getFinishedSpans() + expect(spans.length).toBe(1) + const [span] = spans + expect(span.name).toBe('GET /foo') + expect(span.kind).toBe(SpanKind.SERVER) + expect(span.status.code).toBe(SpanStatusCode.UNSET) + expect(span.status.message).toBeUndefined() + expect(span.attributes[ATTR_HTTP_REQUEST_METHOD]).toBe('GET') + expect(span.attributes[ATTR_URL_FULL]).toBe('http://localhost/foo') + expect(span.attributes[ATTR_HTTP_ROUTE]).toBe('/foo') + expect(span.attributes[ATTR_HTTP_RESPONSE_STATUS_CODE]).toBe(200) + for (const [name, value] of response.headers.entries()) { + expect(span.attributes[ATTR_HTTP_RESPONSE_HEADER(name)]).toBe(value) + } + }) + + it('Should make a span with error', async () => { + memoryExporter.reset() + await app.request('http://localhost/error', { method: 'POST' }) + const spans = memoryExporter.getFinishedSpans() + expect(spans.length).toBe(1) + const [span] = spans + expect(span.name).toBe('POST /error') + expect(span.kind).toBe(SpanKind.SERVER) + expect(span.status.code).toBe(SpanStatusCode.ERROR) + expect(span.status.message).toBe('Error: error message') + expect(span.attributes[ATTR_HTTP_REQUEST_METHOD]).toBe('POST') + expect(span.attributes[ATTR_URL_FULL]).toBe('http://localhost/error') + expect(span.attributes[ATTR_HTTP_ROUTE]).toBe('/error') + }) +}) diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts new file mode 100644 index 00000000..0bf33e1c --- /dev/null +++ b/packages/otel/src/index.ts @@ -0,0 +1,59 @@ +import { SpanKind, SpanStatusCode, type TracerProvider, trace } from '@opentelemetry/api' +import { + ATTR_HTTP_REQUEST_HEADER, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_HEADER, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_URL_FULL, + ATTR_HTTP_ROUTE, +} from '@opentelemetry/semantic-conventions' +import { createMiddleware } from 'hono/factory' +import type { Env, Input } from 'hono' + +const PACKAGE_NAME = '@hono/otel' +const PACKAGE_VERSION = '0.1.0' + +export interface OtelOptions { + tracerProvider?: TracerProvider +} + +export const otel = ( + options: OtelOptions = {} +) => { + const tracerProvider = options.tracerProvider ?? trace.getTracerProvider() + return createMiddleware(async (c, next) => { + const tracer = tracerProvider.getTracer(PACKAGE_NAME, PACKAGE_VERSION) + const route = c.req.matchedRoutes[c.req.matchedRoutes.length - 1] + await tracer.startActiveSpan( + `${c.req.method} ${route.path}`, + { + kind: SpanKind.SERVER, + attributes: { + [ATTR_HTTP_REQUEST_METHOD]: c.req.method, + [ATTR_URL_FULL]: c.req.url, + [ATTR_HTTP_ROUTE]: route.path, + }, + }, + async (span) => { + for (const [name, value] of Object.entries(c.req.header())) { + span.setAttribute(ATTR_HTTP_REQUEST_HEADER(name), value) + } + try { + await next() + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, c.res.status) + for (const [name, value] of c.res.headers.entries()) { + span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(name), value) + } + if (c.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(c.error) }) + } + } catch (e) { + span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }) + throw e + } finally { + span.end() + } + } + ) + }) +} diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json new file mode 100644 index 00000000..acfcd843 --- /dev/null +++ b/packages/otel/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/otel/vitest.config.ts b/packages/otel/vitest.config.ts new file mode 100644 index 00000000..17b54e48 --- /dev/null +++ b/packages/otel/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index dd3c4665..e047daf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2733,6 +2733,22 @@ __metadata: languageName: unknown linkType: soft +"@hono/otel@workspace:packages/otel": + version: 0.0.0-use.local + resolution: "@hono/otel@workspace:packages/otel" + dependencies: + "@opentelemetry/api": "npm:^1.9.0" + "@opentelemetry/sdk-trace-base": "npm:^1.30.0" + "@opentelemetry/sdk-trace-node": "npm:^1.30.0" + "@opentelemetry/semantic-conventions": "npm:^1.28.0" + hono: "npm:^4.4.12" + tsup: "npm:^8.1.0" + vitest: "npm:^1.6.0" + peerDependencies: + hono: "*" + languageName: unknown + linkType: soft + "@hono/prometheus@workspace:packages/prometheus": version: 0.0.0-use.local resolution: "@hono/prometheus@workspace:packages/prometheus" @@ -4124,6 +4140,103 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:^1.9.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add + languageName: node + linkType: hard + +"@opentelemetry/context-async-hooks@npm:1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/context-async-hooks@npm:1.30.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 46fef8f3af37227c16cf4e3d9264bfc7cfbe7357cb4266fa10ef32aa3256da6782110bea997d7a6b6815afb540da0a937fb5ecbaaed248c0234f8872bf25e8df + languageName: node + linkType: hard + +"@opentelemetry/core@npm:1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/core@npm:1.30.0" + dependencies: + "@opentelemetry/semantic-conventions": "npm:1.28.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 52d17b5ddb06ab4241b977ff89b81f69f140edb5c2a78b2188d95fa7bdfdd1aa2dcafb1e2830ab77d557876682ab8f08727ba8f165ea3c39fbb6bf3b86ef33c8 + languageName: node + linkType: hard + +"@opentelemetry/propagator-b3@npm:1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/propagator-b3@npm:1.30.0" + dependencies: + "@opentelemetry/core": "npm:1.30.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 2378d9527247982ad09c08f51b90364913640a72519df3b65fbd694a666f4e13ce035b3a42d3651f5d707e85b3f48b7837e4aa50fbbfe3fcb8f6af47e0af5c34 + languageName: node + linkType: hard + +"@opentelemetry/propagator-jaeger@npm:1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/propagator-jaeger@npm:1.30.0" + dependencies: + "@opentelemetry/core": "npm:1.30.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: a2cd68d3ca08ba84b62427d363f7054a8d51922805376987d67bbf7d61513cde9665a4f5df262f46ed2affae0557d3bc13b0ec3aa68f84088f092f007849f781 + languageName: node + linkType: hard + +"@opentelemetry/resources@npm:1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/resources@npm:1.30.0" + dependencies: + "@opentelemetry/core": "npm:1.30.0" + "@opentelemetry/semantic-conventions": "npm:1.28.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 2b298193de85f8d7d05f9d71e5ea63189668f99248486246a4cfdc8667a5face205d650ef1ee6204a9f9c16d0b0e7704bb89a5d47537279c8e3378231ed35d1d + languageName: node + linkType: hard + +"@opentelemetry/sdk-trace-base@npm:1.30.0, @opentelemetry/sdk-trace-base@npm:^1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/sdk-trace-base@npm:1.30.0" + dependencies: + "@opentelemetry/core": "npm:1.30.0" + "@opentelemetry/resources": "npm:1.30.0" + "@opentelemetry/semantic-conventions": "npm:1.28.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 3d8dcb0ec4e70405593421ea4df8b9a5e7faceea16cb900f30747eaeaa1f96059d40312ff2171208bb627deab6a6f32024681128cfba45af2671c6cfba528af1 + languageName: node + linkType: hard + +"@opentelemetry/sdk-trace-node@npm:^1.30.0": + version: 1.30.0 + resolution: "@opentelemetry/sdk-trace-node@npm:1.30.0" + dependencies: + "@opentelemetry/context-async-hooks": "npm:1.30.0" + "@opentelemetry/core": "npm:1.30.0" + "@opentelemetry/propagator-b3": "npm:1.30.0" + "@opentelemetry/propagator-jaeger": "npm:1.30.0" + "@opentelemetry/sdk-trace-base": "npm:1.30.0" + semver: "npm:^7.5.2" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 284b314c8c5b6da6e7e2b6c6814d6ef7cdfeeb3bce211bc1c38dc1e4b092f811727040265a75b5f6b67c287429cbd23661210b253429370918cb111bef1b57ac + languageName: node + linkType: hard + +"@opentelemetry/semantic-conventions@npm:1.28.0, @opentelemetry/semantic-conventions@npm:^1.28.0": + version: 1.28.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.28.0" + checksum: deb8a0f744198071e70fea27143cf7c9f7ecb7e4d7b619488c917834ea09b31543c1c2bcea4ec5f3cf68797f0ef3549609c14e859013d9376400ac1499c2b9cb + languageName: node + linkType: hard + "@opentelemetry/semantic-conventions@npm:~1.3.0": version: 1.3.1 resolution: "@opentelemetry/semantic-conventions@npm:1.3.1"