-
Notifications
You must be signed in to change notification settings - Fork 200
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# @hono/otel |
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 |
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" | ||
} | ||
} |
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') | ||
}) | ||
}) |
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}`, | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, yeah, it's intended! According to OpenTelemetry's Semantic Conventions for HTTP Spans:
The following guidelines for span names will help you understand what we mean by “low-cardinality”:
|
||||||||||||
{ | ||||||||||||
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() | ||||||||||||
} | ||||||||||||
} | ||||||||||||
) | ||||||||||||
}) | ||||||||||||
} |
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" | ||
], | ||
} |
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, | ||
}, | ||
}) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It must get the version number from
package.json
since Changesets will change the number when the PR is merged automatically.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, as I'm not familiar with packaging JavaScript packages, I have no idea how can I extract the version number from package.json file. Actually, I tried to import it like:
… but it didn't work due to the following errors from
tsc
:Could you give me some idea for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about trying to use the following
tsconfig.json
?