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"