Skip to content
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

feat(otel): Add OpenTelemetry middleware #901

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
25 changes: 25 additions & 0 deletions .github/workflows/ci-otel.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'",
Expand Down
1 change: 1 addition & 0 deletions packages/otel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @hono/otel
37 changes: 37 additions & 0 deletions packages/otel/README.md
Original file line number Diff line number Diff line change
@@ -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 <https://hongminhee.org/>

## License

MIT
53 changes: 53 additions & 0 deletions packages/otel/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
62 changes: 62 additions & 0 deletions packages/otel/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
59 changes: 59 additions & 0 deletions packages/otel/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Comment on lines +13 to +14
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to eliminate the duplication with the metadata in package.json, but couldn't think of a good way to do it.


export interface OtelOptions {
tracerProvider?: TracerProvider
}

export const otel = <E extends Env = any, P extends string = any, I extends Input = {}>(
options: OtelOptions = {}
) => {
const tracerProvider = options.tracerProvider ?? trace.getTracerProvider()
return createMiddleware<E, P, I>(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()
}
}
)
})
}
10 changes: 10 additions & 0 deletions packages/otel/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
],
}
8 changes: 8 additions & 0 deletions packages/otel/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
},
})
113 changes: 113 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down