Skip to content

Commit

Permalink
feat(otel): Add OpenTelemetry middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Jan 6, 2025
1 parent d48ec05 commit c797f1f
Show file tree
Hide file tree
Showing 10 changed files with 392 additions and 1 deletion.
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
66 changes: 66 additions & 0 deletions packages/otel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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
```

## Usage on Cloudflare Workers

Since @opentelemetry/sdk-node is not supported on [Cloudflare Workers](https://workers.cloudflare.com/), you need to use [@microlabs/otel-cf-workers](https://github.com/evanderkoogh/otel-cf-workers) instead.

The following example shows how to use @microlabs/otel-cf-workers with [Honeycomb](https://www.honeycomb.io/):

```ts
import { otel } from '@hono/otel'
import { instrument, ResolveConfigFn } from '@microlabs/otel-cf-workers'
import { Hono } from 'hono'

const app = new Hono()

app.use('*', otel())
app.get('/', (c) => c.text('foo'))

const config: ResolveConfigFn = (env: Env, _trigger) => {
return {
exporter: {
url: 'https://api.honeycomb.io/v1/traces',
headers: { 'x-honeycomb-team': env.HONEYCOMB_API_KEY },
},
service: { name: 'greetings' },
}
}

export default instrument(app, config)
```

## 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'

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,
},
})
Loading

0 comments on commit c797f1f

Please sign in to comment.