diff --git a/.agents/skills/create-adapter/SKILL.md b/.agents/skills/create-adapter/SKILL.md index 9f23714a..b3e8cb8b 100644 --- a/.agents/skills/create-adapter/SKILL.md +++ b/.agents/skills/create-adapter/SKILL.md @@ -71,7 +71,7 @@ Add a build entry in `packages/evlog/tsdown.config.ts` alongside the existing ad 'adapters/{name}': 'src/adapters/{name}.ts', ``` -Place it after the last adapter entry (currently `sentry` at line 22). +Place it after the last adapter entry (currently `hyperdx` in `tsdown.config.ts`). ## Step 3: Package Exports diff --git a/.changeset/add-hyperdx-drain-adapter.md b/.changeset/add-hyperdx-drain-adapter.md new file mode 100644 index 00000000..8ee135f4 --- /dev/null +++ b/.changeset/add-hyperdx-drain-adapter.md @@ -0,0 +1,5 @@ +--- +"evlog": minor +--- + +Add HyperDX drain adapter (`evlog/hyperdx`) for OTLP/HTTP ingest, with defaults aligned to [HyperDX OpenTelemetry documentation](https://hyperdx.io/docs/install/opentelemetry) (`https://in-otel.hyperdx.io`, `authorization` header). Includes docs site and `review-logging-patterns` skill updates. diff --git a/AGENTS.md b/AGENTS.md index 0ffd2f04..dcb0a55f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ evlog/ │ │ ├── vite/ # Vite plugin (evlog/vite) │ │ ├── shared/ # Toolkit: building blocks for custom framework integrations (evlog/toolkit) │ │ ├── ai/ # AI SDK integration (evlog/ai) -│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, PostHog, Sentry, Better Stack) +│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack) │ │ ├── enrichers/ # Built-in enrichers (UserAgent, Geo, RequestSize, TraceContext) │ │ └── runtime/ # Runtime code (client/, server/, utils/) │ └── test/ # Tests @@ -321,6 +321,7 @@ evlog provides built-in adapters for popular observability platforms. Use the `e |---------|--------|-------------| | Axiom | `evlog/axiom` | Send logs to Axiom for querying and dashboards | | OTLP | `evlog/otlp` | OpenTelemetry Protocol for Grafana, Datadog, Honeycomb, etc. | +| HyperDX | `evlog/hyperdx` | Send logs to HyperDX via OTLP/HTTP ([documented](https://hyperdx.io/docs/install/opentelemetry) endpoint and `authorization` header) | | PostHog | `evlog/posthog` | Send logs to PostHog Logs via OTLP for structured logging and observability | | Sentry | `evlog/sentry` | Send logs to Sentry Logs for structured logging and debugging | | Better Stack | `evlog/better-stack` | Send logs to Better Stack for log management and alerting | @@ -351,6 +352,19 @@ export default defineNitroPlugin((nitroApp) => { Set environment variable: `NUXT_OTLP_ENDPOINT`. +**Using HyperDX Adapter:** + +```typescript +// server/plugins/evlog-drain.ts +import { createHyperDXDrain } from 'evlog/hyperdx' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) +}) +``` + +Set environment variable: `NUXT_HYPERDX_API_KEY` or `HYPERDX_API_KEY` (see [HyperDX OpenTelemetry](https://hyperdx.io/docs/install/opentelemetry)). + **Using PostHog Adapter:** ```typescript diff --git a/apps/docs/app/assets/icons/hyperdx.svg b/apps/docs/app/assets/icons/hyperdx.svg new file mode 100644 index 00000000..56c032d6 --- /dev/null +++ b/apps/docs/app/assets/icons/hyperdx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/docs/app/components/app/AppHeaderCenter.vue b/apps/docs/app/components/app/AppHeaderCenter.vue index 079dba88..b73b05dc 100644 --- a/apps/docs/app/components/app/AppHeaderCenter.vue +++ b/apps/docs/app/components/app/AppHeaderCenter.vue @@ -73,6 +73,12 @@ const landingItems = [ description: 'Export via OTLP protocol', to: '/adapters/otlp' }, + { + label: 'HyperDX', + icon: 'i-custom-hyperdx', + description: 'Send logs to HyperDX via OTLP', + to: '/adapters/hyperdx' + }, { label: 'PostHog', icon: 'i-simple-icons-posthog', diff --git a/apps/docs/app/components/features/FeatureAdapters.vue b/apps/docs/app/components/features/FeatureAdapters.vue index ded69a68..86a1ab11 100644 --- a/apps/docs/app/components/features/FeatureAdapters.vue +++ b/apps/docs/app/components/features/FeatureAdapters.vue @@ -384,10 +384,12 @@ function setupCanvas() { -
+
+ File System · Custom drains + · + and more
diff --git a/apps/docs/content/2.frameworks/11.react-router.md b/apps/docs/content/2.frameworks/11.react-router.md index d142a4db..dfcd2069 100644 --- a/apps/docs/content/2.frameworks/11.react-router.md +++ b/apps/docs/content/2.frameworks/11.react-router.md @@ -14,8 +14,8 @@ navigation: The `evlog/react-router` middleware auto-creates a request-scoped logger accessible via `context.get(loggerContext)` or `useLogger()` and emits a wide event when the response completes. -::callout{icon="i-lucide-info" color="info"} -React Router has three [modes](https://reactrouter.com/start/modes): **Framework**, **Data**, and **Declarative**. The `evlog/react-router` middleware requires the middleware API, which is available in **Framework** and **Data** modes only. Declarative mode does not support middleware — use [`evlog/browser`](/core-concepts/client-logging) for client-side logging instead. +::callout{color="info" icon="i-lucide-info"} +React Router has three [modes](https://reactrouter.com/start/modes): **Framework**, **Data**, and **Declarative**. The `evlog/react-router` middleware requires the middleware API, which is available in **Framework** and **Data** modes only. Declarative mode does not support middleware — use `evlog/browser` for client-side logging instead. :: ::code-collapse @@ -102,7 +102,7 @@ export async function loader({ context }: Route.LoaderArgs) { ``` ::callout{color="info" icon="i-custom-vite"} -**Using Vite?** The [`evlog/vite`](/core-concepts/vite-plugin) [plugin](/core-concepts/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. +**Using Vite?** The `evlog/vite` [plugin](/core-concepts/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations. :: The `loggerContext` provides typed access to the evlog logger in any loader or action via `context.get(loggerContext)`. diff --git a/apps/docs/content/4.adapters/1.overview.md b/apps/docs/content/4.adapters/1.overview.md index 1e599157..1e084037 100644 --- a/apps/docs/content/4.adapters/1.overview.md +++ b/apps/docs/content/4.adapters/1.overview.md @@ -15,6 +15,11 @@ links: to: /adapters/otlp color: neutral variant: subtle + - label: HyperDX + icon: i-custom-hyperdx + to: /adapters/hyperdx + color: neutral + variant: subtle - label: PostHog icon: i-simple-icons-posthog to: /adapters/posthog @@ -111,6 +116,15 @@ initLogger({ drain: createAxiomDrain() }) OpenTelemetry Protocol for Grafana, Datadog, Honeycomb, and more. ::: + :::card + --- + icon: i-custom-hyperdx + title: HyperDX + to: /adapters/hyperdx + --- + Send logs to HyperDX via OTLP/HTTP using their documented ingest endpoint and API key. + ::: + :::card --- icon: i-simple-icons-posthog @@ -279,6 +293,9 @@ AXIOM_DATASET=my-logs # OTLP (NUXT_OTLP_* or OTEL_*) OTLP_ENDPOINT=https://otlp.example.com +# HyperDX (NUXT_HYPERDX_* or HYPERDX_*) +HYPERDX_API_KEY= + # PostHog (NUXT_POSTHOG_* or POSTHOG_*) POSTHOG_API_KEY=phc_xxx diff --git a/apps/docs/content/4.adapters/9.pipeline.md b/apps/docs/content/4.adapters/10.pipeline.md similarity index 100% rename from apps/docs/content/4.adapters/9.pipeline.md rename to apps/docs/content/4.adapters/10.pipeline.md diff --git a/apps/docs/content/4.adapters/10.browser.md b/apps/docs/content/4.adapters/11.browser.md similarity index 100% rename from apps/docs/content/4.adapters/10.browser.md rename to apps/docs/content/4.adapters/11.browser.md diff --git a/apps/docs/content/4.adapters/3.otlp.md b/apps/docs/content/4.adapters/3.otlp.md index bcf82ee3..84fdc4d5 100644 --- a/apps/docs/content/4.adapters/3.otlp.md +++ b/apps/docs/content/4.adapters/3.otlp.md @@ -27,6 +27,7 @@ The OTLP (OpenTelemetry Protocol) adapter sends logs in the standard OpenTelemet - **Splunk** - **New Relic** - **Self-hosted OpenTelemetry Collector** +- **HyperDX** ::code-collapse diff --git a/apps/docs/content/4.adapters/8.hyperdx.md b/apps/docs/content/4.adapters/8.hyperdx.md new file mode 100644 index 00000000..e480dd6b --- /dev/null +++ b/apps/docs/content/4.adapters/8.hyperdx.md @@ -0,0 +1,256 @@ +--- +title: HyperDX Adapter +description: Send wide events to HyperDX via OTLP/HTTP using HyperDX’s documented OpenTelemetry endpoint and authorization header. Zero-config setup with environment variables. +navigation: + title: HyperDX + icon: i-custom-hyperdx +links: + - label: HyperDX + icon: i-lucide-external-link + to: https://hyperdx.io + target: _blank + color: neutral + variant: subtle + - label: OTLP Adapter + icon: i-simple-icons-opentelemetry + to: /adapters/otlp + color: neutral + variant: subtle +--- + +[HyperDX](https://hyperdx.io) is an open-source observability platform. The evlog HyperDX adapter sends your wide events to HyperDX using **OTLP over HTTP**, with defaults aligned to [HyperDX’s OpenTelemetry documentation](https://hyperdx.io/docs/install/opentelemetry). + +::code-collapse + +```txt [Prompt] +Add the HyperDX drain adapter to send evlog wide events to HyperDX. + +1. Identify which framework I'm using and follow its evlog integration pattern +2. Install evlog if not already installed +3. Import createHyperDXDrain from 'evlog/hyperdx' +4. Wire createHyperDXDrain() into my framework's drain configuration +5. Set HYPERDX_API_KEY environment variable in .env +6. Test by triggering a request and checking HyperDX + +Adapter docs: https://www.evlog.dev/adapters/hyperdx +Framework setup: https://www.evlog.dev/frameworks +``` + +:: + +## Installation + +The HyperDX adapter comes bundled with evlog: + +```typescript +import { createHyperDXDrain } from 'evlog/hyperdx' +``` + +## Quick Start + +### 1. Get your ingestion API key + +1. Open the [HyperDX](https://hyperdx.io) dashboard for your team +2. Copy your **ingestion API key** (HyperDX documents this as the value for the `authorization` header in their OpenTelemetry examples) + +### 2. Set environment variables + +```bash [.env] +HYPERDX_API_KEY= +``` + +### 3. Wire the drain to your framework + +::code-group +```typescript [Nuxt / Nitro] +// server/plugins/evlog-drain.ts +import { createHyperDXDrain } from 'evlog/hyperdx' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) +}) +``` +```typescript [Hono] +import { createHyperDXDrain } from 'evlog/hyperdx' + +app.use(evlog({ drain: createHyperDXDrain() })) +``` +```typescript [Express] +import { createHyperDXDrain } from 'evlog/hyperdx' + +app.use(evlog({ drain: createHyperDXDrain() })) +``` +```typescript [Fastify] +import { createHyperDXDrain } from 'evlog/hyperdx' + +await app.register(evlog, { drain: createHyperDXDrain() }) +``` +```typescript [Elysia] +import { createHyperDXDrain } from 'evlog/hyperdx' + +app.use(evlog({ drain: createHyperDXDrain() })) +``` +```typescript [NestJS] +import { createHyperDXDrain } from 'evlog/hyperdx' + +EvlogModule.forRoot({ drain: createHyperDXDrain() }) +``` +```typescript [Standalone] +import { createHyperDXDrain } from 'evlog/hyperdx' + +initLogger({ drain: createHyperDXDrain() }) +``` +:: + +That's it! Your wide events will now appear in HyperDX. + +## Configuration + +The adapter reads configuration from multiple sources (highest priority first): + +1. **Overrides** passed to `createHyperDXDrain()` +2. **Runtime config** at `runtimeConfig.evlog.hyperdx` or `runtimeConfig.hyperdx` (Nuxt/Nitro only) +3. **Environment variables** (`HYPERDX_*` or `NUXT_HYPERDX_*`) + +### Environment Variables + +| Variable | Nuxt alias | Description | +|----------|------------|-------------| +| `HYPERDX_API_KEY` | `NUXT_HYPERDX_API_KEY` | Ingestion API key (sent as the `authorization` header) | +| `HYPERDX_OTLP_ENDPOINT` | `NUXT_HYPERDX_OTLP_ENDPOINT` | OTLP HTTP base URL (default: `https://in-otel.hyperdx.io`) | +| `HYPERDX_SERVICE_NAME` | `NUXT_HYPERDX_SERVICE_NAME` | Override `service.name` | + +The following variable is also read when resolving `serviceName` (same as the OTLP adapter): + +| Variable | Description | +|----------|-------------| +| `OTEL_SERVICE_NAME` | Fallback for service name (HyperDX SDK examples use this) | + +::callout{icon="i-lucide-info" color="info"} +In Nuxt/Nitro, use the `NUXT_` prefix so values are available via `useRuntimeConfig()`. In all other frameworks, use the unprefixed variables. +:: + +### Runtime Config (Nuxt only) + +Configure via `nuxt.config.ts` for type-safe configuration: + +```typescript [nuxt.config.ts] +export default defineNuxtConfig({ + runtimeConfig: { + hyperdx: { + apiKey: '', // Set via NUXT_HYPERDX_API_KEY + // endpoint: '', // Set via NUXT_HYPERDX_OTLP_ENDPOINT + }, + }, +}) +``` + +You can also nest keys under `runtimeConfig.evlog.hyperdx`; both match how the adapter resolves Nuxt runtime config. + +### Override Options + +Pass options directly to override any configuration: + +```typescript +const drain = createHyperDXDrain({ + apiKey: process.env.HYPERDX_API_KEY!, + endpoint: 'https://in-otel.hyperdx.io', + timeout: 10000, +}) +``` + +For self-hosted HyperDX, set `endpoint` to your OTLP HTTP base URL (same role as `endpoint` in HyperDX’s `otlphttp` exporter example). + +### Full Configuration Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `apiKey` | `string` | - | Ingestion API key (required). Sent as the `authorization` header value | +| `endpoint` | `string` | `https://in-otel.hyperdx.io` | OTLP HTTP base URL (evlog appends `/v1/logs`) | +| `serviceName` | `string` | - | Override `service.name` resource attribute | +| `resourceAttributes` | `object` | - | Additional OTLP resource attributes | +| `timeout` | `number` | `5000` | Request timeout in milliseconds | +| `retries` | `number` | `2` | Retry attempts on transient failures | + +## How It Works + +Under the hood, `createHyperDXDrain()` maps your HyperDX settings to the shared [OTLP adapter](/adapters/otlp) and calls `sendBatchToOTLP()`: + +- **Endpoint**: OTLP HTTP base URL, defaulting to `https://in-otel.hyperdx.io` (evlog posts to `{endpoint}/v1/logs`) +- **Auth**: `authorization` header set to your API key (same as HyperDX’s documented `otlphttp` exporter) +- **Format**: Standard OTLP JSON `ExportLogsServiceRequest` with severity, trace context when present, and structured attributes + +## Official HyperDX OpenTelemetry reference + +From [HyperDX — OpenTelemetry](https://hyperdx.io/docs/install/opentelemetry): + +> Our OpenTelemetry HTTP endpoint is hosted at `https://in-otel.hyperdx.io` (gRPC at port 4317), and requires the `authorization` header to be set to your API key. + +HyperDX documents this collector configuration (HTTP and gRPC exporters): + +```yaml +exporters: + # HTTP setup + otlphttp/hdx: + endpoint: 'https://in-otel.hyperdx.io' + headers: + authorization: + compression: gzip + + # gRPC setup (alternative) + otlp/hdx: + endpoint: 'in-otel.hyperdx.io:4317' + headers: + authorization: + compression: gzip +``` + +evlog uses the **HTTP** path: JSON to `{endpoint}/v1/logs` with `Content-Type: application/json` and the `authorization` header above. The collector may enable `compression: gzip`; evlog sends uncompressed JSON bodies like typical OTLP HTTP clients. + +## Querying logs in HyperDX + +Use the HyperDX UI to search and explore wide events: + +- **Search**: Filter by fields from your wide events (level, service, path, custom attributes, etc.) +- **Live tail**: Stream incoming logs +- **Dashboards**: Build views on top of structured log data + +## Troubleshooting + +### Missing apiKey error + +``` +[evlog/hyperdx] Missing apiKey. Set HYPERDX_API_KEY or NUXT_HYPERDX_API_KEY, or pass to createHyperDXDrain() +``` + +Make sure your environment variables are set and the server was restarted after adding them. + +### 401 Unauthorized or ingest rejected + +Your API key may be invalid or not permitted to ingest. Confirm the key in HyperDX matches the ingestion key used in their [OpenTelemetry](https://hyperdx.io/docs/install/opentelemetry) examples (`authorization: `). + +## Direct API Usage + +For advanced use cases, you can use the lower-level functions: + +```typescript [server/utils/hyperdx.ts] +import { sendToHyperDX, sendBatchToHyperDX } from 'evlog/hyperdx' + +// Send a single event +await sendToHyperDX(event, { + apiKey: process.env.HYPERDX_API_KEY!, +}) + +// Send multiple events in one request +await sendBatchToHyperDX(events, { + apiKey: process.env.HYPERDX_API_KEY!, + endpoint: 'https://in-otel.hyperdx.io', +}) +``` + +## Next Steps + +- [OTLP Adapter](/adapters/otlp) - Send logs via OpenTelemetry Protocol to any OTLP backend +- [PostHog Adapter](/adapters/posthog) - Send logs to PostHog Logs via OTLP +- [Custom Adapters](/adapters/custom) - Build your own adapter +- [Best Practices](/core-concepts/best-practices) - Security and production tips diff --git a/apps/docs/content/4.adapters/8.custom.md b/apps/docs/content/4.adapters/9.custom.md similarity index 100% rename from apps/docs/content/4.adapters/8.custom.md rename to apps/docs/content/4.adapters/9.custom.md diff --git a/bun.lock b/bun.lock index 9fb5abd7..98589580 100644 --- a/bun.lock +++ b/bun.lock @@ -272,7 +272,7 @@ }, "packages/evlog": { "name": "evlog", - "version": "2.8.0", + "version": "2.9.0", "devDependencies": { "@codspeed/vitest-plugin": "^5.2.0", "@nestjs/common": "^11.1.17", diff --git a/packages/evlog/package.json b/packages/evlog/package.json index 17624d53..8f7aa73e 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -83,6 +83,11 @@ "import": "./dist/adapters/better-stack.mjs", "default": "./dist/adapters/better-stack.mjs" }, + "./hyperdx": { + "types": "./dist/adapters/hyperdx.d.mts", + "import": "./dist/adapters/hyperdx.mjs", + "default": "./dist/adapters/hyperdx.mjs" + }, "./fs": { "types": "./dist/adapters/fs.d.mts", "import": "./dist/adapters/fs.mjs", @@ -203,6 +208,9 @@ "better-stack": [ "./dist/adapters/better-stack.d.mts" ], + "hyperdx": [ + "./dist/adapters/hyperdx.d.mts" + ], "fs": [ "./dist/adapters/fs.d.mts" ], diff --git a/packages/evlog/src/adapters/hyperdx.ts b/packages/evlog/src/adapters/hyperdx.ts new file mode 100644 index 00000000..290da644 --- /dev/null +++ b/packages/evlog/src/adapters/hyperdx.ts @@ -0,0 +1,108 @@ +import type { WideEvent } from '../types' +import type { ConfigField } from './_config' +import { resolveAdapterConfig } from './_config' +import { defineDrain } from './_drain' +import type { OTLPConfig } from './otlp' +import { sendBatchToOTLP } from './otlp' + +/** + * HyperDX cloud OTLP HTTP base URL. + * @see https://hyperdx.io/docs/install/opentelemetry — “Our OpenTelemetry HTTP endpoint is hosted at `https://in-otel.hyperdx.io` …” + */ +export const HYPERDX_DEFAULT_OTLP_HTTP_ENDPOINT = 'https://in-otel.hyperdx.io' + +export interface HyperDXConfig { + /** + * Ingestion API key. Sent as the `authorization` header value, matching HyperDX’s OpenTelemetry docs: + * `authorization: ` + * @see https://hyperdx.io/docs/install/opentelemetry + */ + apiKey: string + /** + * OTLP HTTP base URL (evlog appends `/v1/logs`). Defaults to {@link HYPERDX_DEFAULT_OTLP_HTTP_ENDPOINT}. + * Self-hosted: set to your OTLP HTTP endpoint (same shape as `otlphttp` `endpoint` in HyperDX’s collector example). + */ + endpoint?: string + /** Passed through to the OTLP encoder; maps to `service.name`. */ + serviceName?: string + /** Additional OTLP resource attributes. */ + resourceAttributes?: Record + /** Request timeout in milliseconds. Default: 5000 */ + timeout?: number + /** Number of retry attempts on transient failures. Default: 2 */ + retries?: number +} + +const HYPERDX_FIELDS: ConfigField[] = [ + { key: 'apiKey', env: ['NUXT_HYPERDX_API_KEY', 'HYPERDX_API_KEY'] }, + { key: 'endpoint', env: ['NUXT_HYPERDX_OTLP_ENDPOINT', 'HYPERDX_OTLP_ENDPOINT'] }, + { key: 'serviceName', env: ['NUXT_HYPERDX_SERVICE_NAME', 'HYPERDX_SERVICE_NAME', 'NUXT_OTLP_SERVICE_NAME', 'OTEL_SERVICE_NAME'] }, + { key: 'resourceAttributes' }, + { key: 'timeout' }, + { key: 'retries' }, +] + +/** + * Map HyperDX config to {@link OTLPConfig}: same wire format as HyperDX’s documented `otlphttp` exporter + * (`endpoint` + `authorization` header). + */ +export function toHyperDXOTLPConfig(config: HyperDXConfig): OTLPConfig { + return { + endpoint: config.endpoint ?? HYPERDX_DEFAULT_OTLP_HTTP_ENDPOINT, + headers: { + // HyperDX docs (OpenTelemetry): headers.authorization = API key + authorization: config.apiKey, + }, + serviceName: config.serviceName, + resourceAttributes: config.resourceAttributes, + timeout: config.timeout, + retries: config.retries, + } +} + +/** + * Create a drain that sends wide events to HyperDX via OTLP/HTTP. + * + * Matches [HyperDX OpenTelemetry ingest](https://hyperdx.io/docs/install/opentelemetry): + * HTTP base URL defaults to `https://in-otel.hyperdx.io`; requests use the `authorization` header set to your API key. + * + * Configuration priority (highest to lowest): + * 1. Overrides passed to `createHyperDXDrain()` + * 2. `runtimeConfig.evlog.hyperdx` + * 3. `runtimeConfig.hyperdx` + * 4. Environment variables: `NUXT_HYPERDX_*`, `HYPERDX_*` (and `OTEL_SERVICE_NAME` for service name) + * + * @example + * ```ts + * nitroApp.hooks.hook('evlog:drain', createHyperDXDrain()) + * // HYPERDX_API_KEY in env + * ``` + */ +export function createHyperDXDrain(overrides?: Partial) { + return defineDrain({ + name: 'hyperdx', + resolve: () => { + const config = resolveAdapterConfig('hyperdx', HYPERDX_FIELDS, overrides) + if (!config.apiKey) { + console.error('[evlog/hyperdx] Missing apiKey. Set HYPERDX_API_KEY or NUXT_HYPERDX_API_KEY, or pass to createHyperDXDrain()') + return null + } + return config as HyperDXConfig + }, + send: (events, config) => sendBatchToOTLP(events, toHyperDXOTLPConfig(config)), + }) +} + +/** + * Send a single wide event to HyperDX (OTLP/HTTP). + */ +export async function sendToHyperDX(event: WideEvent, config: HyperDXConfig): Promise { + await sendBatchToHyperDX([event], config) +} + +/** + * Send a batch of wide events to HyperDX (OTLP/HTTP). + */ +export async function sendBatchToHyperDX(events: WideEvent[], config: HyperDXConfig): Promise { + await sendBatchToOTLP(events, toHyperDXOTLPConfig(config)) +} diff --git a/packages/evlog/test/adapters/hyperdx.test.ts b/packages/evlog/test/adapters/hyperdx.test.ts new file mode 100644 index 00000000..728875c7 --- /dev/null +++ b/packages/evlog/test/adapters/hyperdx.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WideEvent } from '../../src/types' +import { + HYPERDX_DEFAULT_OTLP_HTTP_ENDPOINT, + sendBatchToHyperDX, + sendToHyperDX, + toHyperDXOTLPConfig, +} from '../../src/adapters/hyperdx' + +describe('hyperdx adapter', () => { + let fetchSpy: ReturnType + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const createTestEvent = (overrides?: Partial): WideEvent => ({ + timestamp: '2024-01-01T12:00:00.000Z', + level: 'info', + service: 'test-service', + environment: 'test', + ...overrides, + }) + + describe('toHyperDXOTLPConfig', () => { + it('defaults endpoint to HyperDX cloud OTLP HTTP URL from docs', () => { + const otlp = toHyperDXOTLPConfig({ + apiKey: 'hdx-test-key', + }) + expect(otlp.endpoint).toBe(HYPERDX_DEFAULT_OTLP_HTTP_ENDPOINT) + expect(otlp.endpoint).toBe('https://in-otel.hyperdx.io') + }) + + it('sets authorization header to the API key per HyperDX OpenTelemetry docs', () => { + const otlp = toHyperDXOTLPConfig({ + apiKey: 'my-ingestion-key', + }) + expect(otlp.headers).toEqual({ + authorization: 'my-ingestion-key', + }) + }) + }) + + describe('sendToHyperDX', () => { + it('POSTs OTLP JSON to default cloud endpoint /v1/logs', async () => { + const event = createTestEvent() + + await sendToHyperDX(event, { + apiKey: 'k', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://in-otel.hyperdx.io/v1/logs') + }) + + it('uses custom OTLP HTTP base URL when endpoint is overridden', async () => { + const event = createTestEvent() + + await sendToHyperDX(event, { + apiKey: 'k', + endpoint: 'https://otel.my-company.internal', + }) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://otel.my-company.internal/v1/logs') + }) + + it('sends authorization header with API key', async () => { + const event = createTestEvent() + + await sendToHyperDX(event, { + apiKey: 'secret-hdx-key', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + authorization: 'secret-hdx-key', + 'Content-Type': 'application/json', + })) + }) + + it('sends valid OTLP resourceLogs payload', async () => { + const event = createTestEvent() + + await sendToHyperDX(event, { + apiKey: 'k', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const payload = JSON.parse(options.body as string) + expect(payload).toHaveProperty('resourceLogs') + expect(payload.resourceLogs[0].scopeLogs[0].scope.name).toBe('evlog') + }) + }) + + describe('sendBatchToHyperDX', () => { + it('no-ops on empty batch', async () => { + await sendBatchToHyperDX([], { apiKey: 'k' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index defdc700..fa7854d7 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ 'adapters/posthog': 'src/adapters/posthog.ts', 'adapters/sentry': 'src/adapters/sentry.ts', 'adapters/better-stack': 'src/adapters/better-stack.ts', + 'adapters/hyperdx': 'src/adapters/hyperdx.ts', 'adapters/fs': 'src/adapters/fs.ts', 'enrichers': 'src/enrichers/index.ts', 'pipeline': 'src/pipeline.ts', diff --git a/skills/review-logging-patterns/SKILL.md b/skills/review-logging-patterns/SKILL.md index cdf8a487..232be845 100644 --- a/skills/review-logging-patterns/SKILL.md +++ b/skills/review-logging-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: review-logging-patterns -description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, PostHog, Sentry, Better Stack), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics). +description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics). license: MIT metadata: author: HugoRCD @@ -723,6 +723,7 @@ All options work in Nuxt (`evlog` key), Nitro (passed to `evlog()`), Next.js (`c |---------|--------|----------| | Axiom | `evlog/axiom` | `AXIOM_TOKEN`, `AXIOM_DATASET` | | OTLP | `evlog/otlp` | `OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_ENDPOINT`) | +| HyperDX | `evlog/hyperdx` | `HYPERDX_API_KEY` (optional `HYPERDX_OTLP_ENDPOINT`; defaults to `https://in-otel.hyperdx.io`) | | PostHog | `evlog/posthog` | `POSTHOG_API_KEY`, `POSTHOG_HOST` | | Sentry | `evlog/sentry` | `SENTRY_DSN` | | Better Stack | `evlog/better-stack` | `BETTER_STACK_SOURCE_TOKEN` |