10 Commits
Author SHA1 Message Date
semantic-release-bot 2134ba58b8 chore(release): 1.1.0 [skip ci]
# [1.1.0](https://git.mifi.dev/mifi/logger/compare/v1.0.1...v1.1.0) (2026-08-21)

### Features

* now supports `async` functions for logging data ([c2bcc6c](c2bcc6c82c))
* now supports `async` functions for logging data ([16550ec](16550ec8bb))
2026-08-21 14:36:17 +00:00
mifi c2bcc6c82c feat: now supports async functions for logging data
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
- `logger.debug('message', async () => ({ asyncReturn: await asyncFn() }))` is now supported
- NOTE: this may result in logging occurring out of band for these calls if other events fire befor they settle

Update CI labels to direct jobs to correct servers
2026-08-21 11:34:33 -03:00
mifi 16550ec8bb feat: now supports async functions for logging data
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish unknown status
- `logger.debug('message', async () => ({ asyncReturn: await asyncFn() }))` is now supported
- NOTE: this may result in logging occurring out of band for these calls if other events fire befor they settle

Update CI labels to direct jobs to correct servers
2026-08-21 11:24:34 -03:00
semantic-release-bot 292f44821d chore(release): 1.0.1 [skip ci]
## [1.0.1](https://git.mifi.dev/mifi/logger/compare/v1.0.0...v1.0.1) (2026-08-07)

### Bug Fixes

* **sentry:** widen SentryLike so official SDKs assign without adapters ([9f6f6e4](9f6f6e4df5))
* **sentry:** widen SentryLike so official SDKs assign without adapters ([3c468db](3c468dbf44))
2026-08-07 19:08:20 +00:00
mifi 9f6f6e4df5 fix(sentry): widen SentryLike so official SDKs assign without adapters
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-08-07 16:06:22 -03:00
mifi 3c468dbf44 fix(sentry): widen SentryLike so official SDKs assign without adapters
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish unknown status
2026-08-07 16:05:09 -03:00
semantic-release-bot 19de8db119 chore(release): 1.0.0 [skip ci]
# [1.0.0](https://git.mifi.dev/mifi/logger/compare/v0.10.0...v1.0.0) (2026-08-07)

### Features

* API options object support to control sentry handling; removal of `logger.sentry.` ([234226e](234226e2bb))
* API options object support to control sentry handling; removal of `logger.sentry.` ([6bee3a8](6bee3a801d))

### BREAKING CHANGES

* API options object support to control sentry handling; removal of `logger.sentry.`
* API options object support to control sentry handling; removal of `logger.sentry.`
2026-08-07 15:22:51 +00:00
mifi 9bcf28c9dc Semvar fixes
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-08-07 12:20:52 -03:00
mifi 234226e2bb feat!: API options object support to control sentry handling; removal of logger.sentry.
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
# Conflicts:
#	README.md
#	src/logger.ts
#	src/sinks/sentry.ts
#	test/logger.test.ts
2026-08-07 12:10:38 -03:00
mifi 6bee3a801d feat!: API options object support to control sentry handling; removal of logger.sentry.
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish unknown status
2026-08-07 12:07:29 -03:00
23 changed files with 1927 additions and 246 deletions
+1 -1
View File
@@ -1 +1 @@
{ "singleQuote": false, "tabWidth": 4, "trailingComma": "all", "printWidth": 100 } { "singleQuote": false, "tabWidth": 4, "trailingComma": "all", "printWidth": 100, "semi": true }
-57
View File
@@ -1,57 +0,0 @@
# One-time bootstrap for the v0.9.1 tag, whose original tag workflow failed before publishing.
when:
- event: manual
steps:
publish-v0-9-1:
image: node:24-bookworm-slim
environment:
NPM_TOKEN:
from_secret: gitea_package_token
commands:
- corepack enable
- corepack prepare pnpm@11.0.0 --activate
- apt-get update && apt-get install --yes --no-install-recommends ca-certificates git
- pnpm install --frozen-lockfile
- test "$(node -p \"require('./package.json').version\")" = "0.9.1"
- git rev-parse --verify refs/tags/v0.9.1
- pnpm check
- pnpm test
- pnpm build
- npm config set @mifi:registry https://git.mifi.dev/api/packages/mifi/npm/
- npm config set -- //git.mifi.dev/api/packages/mifi/npm/:_authToken "$NPM_TOKEN"
- pnpm publish --no-git-checks
notify-bootstrap-failure:
image: curlimages/curl:8.14.1
depends_on: [publish-v0-9-1]
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Package bootstrap failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl --fail --show-error --silent --request POST --header 'Content-Type: application/json' --header "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" --data "$BODY" "$MATTERMOST_POST_API_URL"
when:
- status: [failure]
notify-bootstrap-success:
image: curlimages/curl:8.14.1
depends_on: [publish-v0-9-1]
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Package bootstrap success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl --fail --show-error --silent --request POST --header 'Content-Type: application/json' --header "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" --data "$BODY" "$MATTERMOST_POST_API_URL"
when:
- status: [success]
+3
View File
@@ -3,6 +3,9 @@ when:
- event: push - event: push
branch: main branch: main
labels:
performance: low
steps: steps:
verify: verify:
image: node:24-bookworm-slim image: node:24-bookworm-slim
+3
View File
@@ -6,6 +6,9 @@ when:
depends_on: depends_on:
- ci - ci
labels:
performance: high
clone: clone:
git: git:
image: woodpeckerci/plugin-git image: woodpeckerci/plugin-git
+28 -2
View File
@@ -1,9 +1,35 @@
# [0.10.0](https://git.mifi.dev/mifi/logger/compare/v0.9.3...v0.10.0) (2026-08-05) # [1.1.0](https://git.mifi.dev/mifi/logger/compare/v1.0.1...v1.1.0) (2026-08-21)
### Features ### Features
* **logger:** add explicit Sentry event channel ([44f4716](https://git.mifi.dev/mifi/logger/commit/44f471653e8d4045e87886da1e1bbe04edbd60af)) * now supports `async` functions for logging data ([c2bcc6c](https://git.mifi.dev/mifi/logger/commit/c2bcc6c82c675640b85e97f1d78dd7c6e627d8f0))
* now supports `async` functions for logging data ([16550ec](https://git.mifi.dev/mifi/logger/commit/16550ec8bb7810ccc36c36cbaab5542e4ad66dae))
## [1.0.1](https://git.mifi.dev/mifi/logger/compare/v1.0.0...v1.0.1) (2026-08-07)
### Bug Fixes
- **sentry:** widen SentryLike so official SDKs assign without adapters ([9f6f6e4](https://git.mifi.dev/mifi/logger/commit/9f6f6e4df52449a84111656a939fda5f59816ea5))
- **sentry:** widen SentryLike so official SDKs assign without adapters ([3c468db](https://git.mifi.dev/mifi/logger/commit/3c468dbf44caf119b4ff769cbc158a4c3d9f6c54))
# [1.0.0](https://git.mifi.dev/mifi/logger/compare/v0.10.0...v1.0.0) (2026-08-07)
### Features
- API options object support to control sentry handling; removal of `logger.sentry.` ([234226e](https://git.mifi.dev/mifi/logger/commit/234226e2bbb79acf3b2686890677bd7326c6a0e0))
- API options object support to control sentry handling; removal of `logger.sentry.` ([6bee3a8](https://git.mifi.dev/mifi/logger/commit/6bee3a801da054a613e84d7985050e7e4e367194))
### BREAKING CHANGES
- API options object support to control sentry handling; removal of `logger.sentry.`
- API options object support to control sentry handling; removal of `logger.sentry.`
# [0.10.0](https://git.mifi.dev/mifi/logger/compare/v0.9.3...v0.10.0) (2026-08-05)
### Features
- **logger:** add explicit Sentry event channel ([44f4716](https://git.mifi.dev/mifi/logger/commit/44f471653e8d4045e87886da1e1bbe04edbd60af))
## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05) ## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05)
+180 -13
View File
@@ -5,41 +5,208 @@ Intentional, namespaced logging for TypeScript browser and Node.js applications.
```ts ```ts
import { createLogger } from "@mifi/logger"; import { createLogger } from "@mifi/logger";
const logger = createLogger({ environment: "production" }); const logger = createLogger({ environment: "production", namespace: "WIDGET" });
logger.child("WIDGET").debug("Rendered", () => ({ expensive: "only evaluated when shown" })); logger.child("Button").debug("Rendered", () => ({ expensive: "only evaluated when shown" }));
``` ```
## Install
```bash
pnpm add @mifi/logger
```
Requires Node.js `>=24`. Published to the private `@mifi` registry.
## Quick start
```ts
import { createLogger } from "@mifi/logger";
const logger = createLogger({
environment: "development", // "development" | "staging" | "production"
namespace: "API", // optional; string or string[] joined with ":"
});
logger.trace("very detailed");
logger.debug("diagnostic", () => ({ snapshot: heavyWork() }));
logger.info("ready");
logger.warn("slow response", { ms: 1200 });
logger.error("request failed", error);
logger.assert(userId, "missing user id");
const auth = logger.child("auth"); // namespace → "API:auth"
auth.info("token refreshed");
```
Console utility methods (`group`, `groupCollapsed`, `table`, `time`, `count`, `dir`, …) are available and gated by the same level policy as `info`.
## Runtime policy ## Runtime policy
Development shows every level, staging shows `warn` and `error`, and production shows `error` only. Browser sessions can override the policy with `sessionStorage.showLoggingFor`: Default levels by environment:
```text | Environment | Default level | Console shows |
debug | ------------- | ------------- | --------------- |
debug:WIDGET,API | `development` | `trace` | all levels |
| `staging` | `warn` | `warn`, `error` |
| `production` | `error` | `error` only |
Levels in order (most → least verbose): `trace``debug``info``warn``error``silent`.
### Override precedence
Highest wins:
1. Explicit `level` option on `createLogger`
2. Browser `sessionStorage.showLoggingFor`
3. Env vars `MIFI_LOG_LEVEL` / `MIFI_LOG_NAMESPACES`
4. Environment default (table above)
### Browser session override
```js
sessionStorage.setItem("showLoggingFor", "debug");
// or restrict to namespaces (exact or descendants):
sessionStorage.setItem("showLoggingFor", "debug:WIDGET,API");
``` ```
Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level` options take precedence. `WIDGET` matches `WIDGET` and `WIDGET:Button`.
### Container / Node env overrides
See [Environment variables](#environment-variables) and [`env.example`](./env.example).
```bash
MIFI_LOG_LEVEL=debug
MIFI_LOG_NAMESPACES=WIDGET,API
```
## Environment variables
These are read from `process.env` (or from `options.env` when testing/injecting):
| Variable | Description |
| --------------------- | --------------------------------------------------------------------------- |
| `MIFI_LOG_LEVEL` | Override level: `trace`, `debug`, `info`, `warn`, `error`, or `silent`. |
| `MIFI_LOG_NAMESPACES` | Optional comma-separated namespace filters applied with the level override. |
When only `MIFI_LOG_LEVEL` is set, all namespaces are enabled at that level. When `MIFI_LOG_NAMESPACES` is also set, only matching namespaces (and their children) emit at that level.
Copy [`env.example`](./env.example) as a starting point for local or container config.
## Sentry ## Sentry
The Sentry adapter has no SDK dependency. Provide the SDK that your application already uses. In a production browser, errors go to Sentry without also appearing in the console. In Node, they go to both Sentry and stderr. Use `logger.sentry` to explicitly capture selected non-error events without changing the default policy or writing them to the console when they are otherwise suppressed. The Sentry adapter has **no SDK dependency**. Pass your official `@sentry/node`, `@sentry/browser`, or `@sentry/nextjs` module directly to `createSentrySink` — no consumer adapter or cast is required.
**Development never sends to Sentry.** Staging and production attach the sink when `sentry` is configured.
Destination policy when `sentry` is configured and `sinks` is not:
| Runtime + environment | Console | Sentry |
| ---------------------- | ---------------------- | -------------------------------- |
| Production **browser** | Off | Issues for errors; optional Logs |
| Production **Node** | On (stderr for errors) | Issues for errors; optional Logs |
| Staging | On (default `warn`+) | Issues for errors; optional Logs |
| Development | On | Nothing |
### Issues vs Logs
- **Errors** → Sentry Issues (`captureException` / `captureMessage`). Not also written to Logs.
- **Warnings and lower** → Sentry Logs (`sentry.logger.*`) only when `{ logs: true }` on the sink (or forced per call).
Default Sentry Logs thresholds when `logs` is enabled:
| Environment | Logs threshold |
| ----------- | -------------- |
| Staging | `info` |
| Production | `warn` |
Override with `logLevel` on `createSentrySink`. Console policy stays independent — production browsers can stay quiet while warnings still appear in Sentry Logs.
### Per-call options
`trace` / `debug` / `log` / `info` / `warn` / `error` (and `assert`) accept a trailing options object. Console utilities (`group`, `time`, …) do not.
```ts ```ts
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@mifi/logger"; import { createLogger } from "@mifi/logger";
import { createSentrySink } from "@mifi/logger/sentry"; import { createSentrySink } from "@mifi/logger/sentry";
const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) }); const logger = createLogger({
environment: "production",
namespace: "API",
sentry: createSentrySink(Sentry, { logs: true }),
});
logger.sentry.info("Cache warmed", { entries: 42 }); logger.error("Request failed", error, { requestId: "req_1" });
logger.warn("slow", { ms: 1200 }); // → Sentry Logs in production
logger.info("investigating", { orderId }, { sentry: true }); // force Logs
logger.error("expected", err, { suppressSentry: true }); // console only (when enabled)
``` ```
| Option | Effect |
| ---------------------- | ------------------------------------------------------------------------ |
| `sentry: true` | Force this event to Sentry Logs (ignores Logs threshold; not for errors) |
| `suppressSentry: true` | Skip creating a Sentry Issue for an error |
A last argument is treated as options only when every key is `sentry` or `suppressSentry`. Prefer the third-argument form when you also pass data.
Logs payload: string message + flat primitive attributes (including `logger.namespace`). Nested objects are not unfurled.
Issue severities use Sentrys union (`warning`, not logger `warn`). The sink maps `warn``warning` and `trace``debug` for Issues only; Logs keep logger level names (`sentry.logger.warn`).
## Custom sinks
Replace the default destinations entirely with `sinks`:
```ts
import { createLogger, createConsoleSink, type LogSink } from "@mifi/logger";
const analyticsSink: LogSink = {
emit(event) {
if (event.sendToConsole) analytics.track("log", event);
},
};
const logger = createLogger({
environment: "production",
sinks: [createConsoleSink(), analyticsSink],
});
```
When `sinks` is provided, the `sentry` option is not used for default sink selection.
## API overview
| Export | From | Purpose |
| ---------------------- | --------------------- | ------------------------------------------------ |
| `createLogger` | `@mifi/logger` | Build a namespaced, Console-shaped logger |
| `createConsoleSink` | `@mifi/logger` | Default styled console destination |
| `parseLoggingOverride` | `@mifi/logger` | Parse `debug` / `debug:NS1,NS2` override strings |
| `createSentrySink` | `@mifi/logger/sentry` | Adapter for any Sentry-like SDK |
Types: `Logger`, `LoggerOptions`, `LogLevel`, `LoggerEnvironment`, `LogEvent`, `LogSink`, `LogData`, `LogCallOptions`, and (from `/sentry`) `SentryLike`, `SentryScopeLike`, `SentrySeverityLevel`, `SentrySink`, `SentrySinkOptions`.
Optional destinations live under `src/sinks/` (console, Sentry today; New Relic / Datadog-style adapters can land beside them later). Public import paths stay `@mifi/logger` and `@mifi/logger/sentry`.
All public APIs include JSDoc with parameter and example documentation in the TypeScript sources.
## Lazy arguments
Any function passed **after** the first argument is invoked only if the event is emitted:
```ts
logger.debug("state", () => buildHugeSnapshot()); // skipped when debug is suppressed
logger.debug("response", async () => ({ data: await res.json(), url }));
```
Top-level thenables (including Promises returned from lazy factories, or a Promise passed directly as an argument) are settled before sinks run. The log call stays fire-and-forget (`void`). Settled events set `LogEvent.async`, and the console sink renders an `[async]` label suffix. Nested promises inside plain objects are not walked — compose them inside the async factory. Async emits may appear out of order relative to sync logs.
Rejection reasons replace rejected thenables in that argument slot; other arguments still emit.
## Releases ## Releases
Woodpecker verifies pull requests and `main`, reports CI to Mattermost, then automatically releases from Conventional Commits merged to `main`. It updates `package.json` and `CHANGELOG.md`, creates a `vX.Y.Z` tag, and publishes to the private `@mifi` registry. Woodpecker verifies pull requests and `main`, reports CI to Mattermost, then automatically releases from Conventional Commits merged to `main`. It updates `package.json` and `CHANGELOG.md`, creates a `vX.Y.Z` tag, and publishes to the private `@mifi` registry.
Use `fix:` for a patch, `feat:` for a minor release, and `!` or a `BREAKING CHANGE:` footer for a major release. Commits such as `docs:`, `test:`, and `chore:` do not release a package. Before enabling the automation, publish and tag the existing `v0.9.1` once as its baseline. Use `fix:` for a patch, `feat:` for a minor release, and `feat!` / `fix!` (or a `BREAKING CHANGE:` footer) for a major release. Commits such as `docs:`, `test:`, and `chore:` do not release a package.
The release workflow uses the existing global `gitea_package_token`, `gitea_registry_username`, `gitea_release_token`, `mattermost_bot_access_token`, `mattermost_tests_channel_id`, `mattermost_pushes_channel_id`, and `mattermost_post_api_url` secrets. The release workflow uses the existing global `gitea_package_token`, `gitea_registry_username`, `gitea_release_token`, `mattermost_bot_access_token`, `mattermost_tests_channel_id`, `mattermost_pushes_channel_id`, and `mattermost_post_api_url` secrets.
The repository includes a guarded, one-time manual bootstrap workflow for `v0.9.1`; remove it after that initial package version has been published.
+19
View File
@@ -0,0 +1,19 @@
# @mifi/logger — environment variables
#
# Copy this file or set the variables in your container / process environment.
# Explicit `level` on createLogger() always takes precedence over these values.
# In browsers, sessionStorage.showLoggingFor is checked before these env vars.
# Override the log level for this process.
# One of: trace | debug | info | warn | error | silent
# When unset, the level defaults from the logger's `environment` option:
# development → trace
# staging → warn
# production → error
# MIFI_LOG_LEVEL=debug
# Optional comma-separated namespace filters applied together with MIFI_LOG_LEVEL.
# A filter matches the namespace exactly or as a prefix of a child
# (e.g. WIDGET matches WIDGET and WIDGET:Button).
# When unset (and MIFI_LOG_LEVEL is set), all namespaces are enabled at that level.
# MIFI_LOG_NAMESPACES=WIDGET,API
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mifi/logger", "name": "@mifi/logger",
"version": "0.10.0", "version": "1.1.0",
"description": "Intentional, namespaced logging for browser and Node.js TypeScript applications.", "description": "Intentional, namespaced logging for browser and Node.js TypeScript applications.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -37,6 +37,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"lint": "oxlint src test", "lint": "oxlint src test",
"lint:fix": "oxlint src test --fix",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"pack:check": "pnpm pack --pack-destination .artifacts", "pack:check": "pnpm pack --pack-destination .artifacts",
@@ -45,6 +46,7 @@
"devDependencies": { "devDependencies": {
"@semantic-release/changelog": "^7.0.0", "@semantic-release/changelog": "^7.0.0",
"@semantic-release/git": "^11.0.1", "@semantic-release/git": "^11.0.1",
"@sentry/node": "^10.69.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"oxlint": "^1.0.0", "oxlint": "^1.0.0",
+368
View File
@@ -13,6 +13,9 @@ importers:
"@semantic-release/git": "@semantic-release/git":
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.0.1(semantic-release@25.0.8(typescript@6.0.3)) version: 11.0.1(semantic-release@25.0.8(typescript@6.0.3))
"@sentry/node":
specifier: ^10.69.0
version: 10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))
"@types/node": "@types/node":
specifier: ^24.0.0 specifier: ^24.0.0
version: 24.13.3 version: 24.13.3
@@ -70,6 +73,26 @@ packages:
} }
engines: { node: ">=6.0.0" } engines: { node: ">=6.0.0" }
"@apm-js-collab/code-transformer-bundler-plugins@0.7.4":
resolution:
{
integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==,
}
engines: { node: ">=18.0.0" }
"@apm-js-collab/code-transformer@0.18.1":
resolution:
{
integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==,
}
hasBin: true
"@apm-js-collab/tracing-hooks@0.13.0":
resolution:
{
integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==,
}
"@babel/code-frame@7.29.7": "@babel/code-frame@7.29.7":
resolution: resolution:
{ {
@@ -730,6 +753,72 @@ packages:
integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==, integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==,
} }
"@opentelemetry/api-logs@0.220.0":
resolution:
{
integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==,
}
engines: { node: ">=8.0.0" }
"@opentelemetry/api@1.9.1":
resolution:
{
integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==,
}
engines: { node: ">=8.0.0" }
"@opentelemetry/core@2.10.0":
resolution:
{
integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==,
}
engines: { node: ^18.19.0 || >=20.6.0 }
peerDependencies:
"@opentelemetry/api": ">=1.0.0 <1.10.0"
"@opentelemetry/instrumentation@0.220.0":
resolution:
{
integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==,
}
engines: { node: ^18.19.0 || >=20.6.0 }
peerDependencies:
"@opentelemetry/api": ^1.3.0
"@opentelemetry/resources@2.10.0":
resolution:
{
integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==,
}
engines: { node: ^18.19.0 || >=20.6.0 }
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
"@opentelemetry/sdk-trace-base@2.10.0":
resolution:
{
integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==,
}
engines: { node: ^18.19.0 || >=20.6.0 }
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
"@opentelemetry/sdk-trace@2.10.0":
resolution:
{
integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==,
}
engines: { node: ^18.19.0 || >=20.6.0 }
peerDependencies:
"@opentelemetry/api": ">=1.3.0 <1.10.0"
"@opentelemetry/semantic-conventions@1.43.0":
resolution:
{
integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==,
}
engines: { node: ">=14" }
"@oxlint/binding-android-arm-eabi@1.77.0": "@oxlint/binding-android-arm-eabi@1.77.0":
resolution: resolution:
{ {
@@ -1217,6 +1306,69 @@ packages:
peerDependencies: peerDependencies:
semantic-release: ">=20.1.0" semantic-release: ">=20.1.0"
"@sentry/conventions@0.16.0":
resolution:
{
integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==,
}
engines: { node: ">=14" }
"@sentry/core@10.69.0":
resolution:
{
integrity: sha512-+uuqVEeiDzYuAKjZLqsROKXvRTbl/QeH0gfGRtpYib1cud4rAFWRIkFmcR7Jb7JGFYwmReyQotiTj/hcDszTZg==,
}
engines: { node: ">=18" }
"@sentry/node-core@10.69.0":
resolution:
{
integrity: sha512-IgArHczrZJxkgxoffHscj0NxQrG6kCazgmGQnlf3j58J1ec21YaUu8Tu+7G4Lo5tCiW3teQnwlKW1ttMXSqWRw==,
}
engines: { node: ">=18" }
peerDependencies:
"@opentelemetry/api": ^1.9.0
"@opentelemetry/core": ^1.30.1 || ^2.1.0
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1"
"@opentelemetry/instrumentation": ">=0.57.1 <1"
"@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.1.0
peerDependenciesMeta:
"@opentelemetry/api":
optional: true
"@opentelemetry/core":
optional: true
"@opentelemetry/exporter-trace-otlp-http":
optional: true
"@opentelemetry/instrumentation":
optional: true
"@opentelemetry/sdk-trace-base":
optional: true
"@sentry/node@10.69.0":
resolution:
{
integrity: sha512-xEXA1YGIiTZbrW6MWV34uS6JGQuQg2ijTI0zed+FsJb9JZKPYel/GZK8Km26vfTVb+yCXFmWZNBesKegNcVdzg==,
}
engines: { node: ">=18" }
"@sentry/opentelemetry@10.69.0":
resolution:
{
integrity: sha512-3FyWV6YcEJuvLrlaKGE1dHXCI+1YO0a62w7PkwlRg8yp6K6YXkmdwu9GjqaYD+Ju4tm7uC7mHIsGFQMm0M7pqQ==,
}
engines: { node: ">=18" }
peerDependencies:
"@opentelemetry/api": ^1.9.0
"@opentelemetry/core": ^1.30.1 || ^2.1.0
"@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.1.0
"@sentry/server-utils@10.69.0":
resolution:
{
integrity: sha512-0MwHrA8+nNvMIsqf8m3cXwCBlUjr6AS7N6CZvHJtY1DkqEvQqEbD5VIrhzEyHN/KMZIgQ8XeDCQRhjnXFQGRhg==,
}
engines: { node: ">=18" }
"@simple-libs/stream-utils@1.2.0": "@simple-libs/stream-utils@1.2.0":
resolution: resolution:
{ {
@@ -1431,6 +1583,13 @@ packages:
integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==, integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==,
} }
astring@1.9.0:
resolution:
{
integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==,
}
hasBin: true
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: resolution:
{ {
@@ -1548,6 +1707,12 @@ packages:
} }
engines: { node: ">= 14.16.0" } engines: { node: ">= 14.16.0" }
cjs-module-lexer@2.2.0:
resolution:
{
integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==,
}
clean-stack@5.3.0: clean-stack@5.3.0:
resolution: resolution:
{ {
@@ -1825,6 +1990,12 @@ packages:
integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==,
} }
es-module-lexer@2.3.1:
resolution:
{
integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==,
}
esbuild@0.27.7: esbuild@0.27.7:
resolution: resolution:
{ {
@@ -1862,6 +2033,20 @@ packages:
} }
engines: { node: ">=12" } engines: { node: ">=12" }
esquery@1.7.0:
resolution:
{
integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==,
}
engines: { node: ">=0.10" }
estraverse@5.3.0:
resolution:
{
integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==,
}
engines: { node: ">=4.0" }
estree-walker@3.0.3: estree-walker@3.0.3:
resolution: resolution:
{ {
@@ -2143,6 +2328,13 @@ packages:
} }
engines: { node: ">=18.20" } engines: { node: ">=18.20" }
import-in-the-middle@3.3.3:
resolution:
{
integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==,
}
engines: { node: ">=18" }
import-meta-resolve@4.2.0: import-meta-resolve@4.2.0:
resolution: resolution:
{ {
@@ -2491,6 +2683,13 @@ packages:
integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==,
} }
meriyah@6.1.4:
resolution:
{
integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==,
}
engines: { node: ">=18.0.0" }
micromatch@4.0.8: micromatch@4.0.8:
resolution: resolution:
{ {
@@ -2546,6 +2745,12 @@ packages:
integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==, integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==,
} }
module-details-from-path@1.0.4:
resolution:
{
integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==,
}
ms@2.1.3: ms@2.1.3:
resolution: resolution:
{ {
@@ -3070,6 +3275,13 @@ packages:
} }
engines: { node: ">=0.10.0" } engines: { node: ">=0.10.0" }
require-in-the-middle@8.0.1:
resolution:
{
integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==,
}
engines: { node: ">=9.3.0 || >=8.10.0 <9.0.0" }
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: resolution:
{ {
@@ -3106,6 +3318,12 @@ packages:
engines: { node: ^22.14.0 || >= 24.10.0 } engines: { node: ^22.14.0 || >= 24.10.0 }
hasBin: true hasBin: true
semifies@1.0.0:
resolution:
{
integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==,
}
semver-regex@4.0.5: semver-regex@4.0.5:
resolution: resolution:
{ {
@@ -3852,6 +4070,30 @@ snapshots:
"@jridgewell/gen-mapping": 0.3.13 "@jridgewell/gen-mapping": 0.3.13
"@jridgewell/trace-mapping": 0.3.31 "@jridgewell/trace-mapping": 0.3.31
"@apm-js-collab/code-transformer-bundler-plugins@0.7.4":
dependencies:
"@apm-js-collab/code-transformer": 0.18.1
es-module-lexer: 2.3.1
magic-string: 0.30.21
module-details-from-path: 1.0.4
"@apm-js-collab/code-transformer@0.18.1":
dependencies:
"@types/estree": 1.0.9
astring: 1.9.0
esquery: 1.7.0
meriyah: 6.1.4
semifies: 1.0.0
source-map: 0.6.1
"@apm-js-collab/tracing-hooks@0.13.0":
dependencies:
"@apm-js-collab/code-transformer": 0.18.1
debug: 4.4.3
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
"@babel/code-frame@7.29.7": "@babel/code-frame@7.29.7":
dependencies: dependencies:
"@babel/helper-validator-identifier": 7.29.7 "@babel/helper-validator-identifier": 7.29.7
@@ -4126,6 +4368,49 @@ snapshots:
dependencies: dependencies:
"@octokit/openapi-types": 28.0.0 "@octokit/openapi-types": 28.0.0
"@opentelemetry/api-logs@0.220.0":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/api@1.9.1": {}
"@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/semantic-conventions": 1.43.0
"@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/api-logs": 0.220.0
import-in-the-middle: 3.3.3
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
"@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/core": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/semantic-conventions": 1.43.0
"@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/core": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/resources": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/sdk-trace": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/semantic-conventions": 1.43.0
"@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/core": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/resources": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/semantic-conventions": 1.43.0
"@opentelemetry/semantic-conventions@1.43.0": {}
"@oxlint/binding-android-arm-eabi@1.77.0": "@oxlint/binding-android-arm-eabi@1.77.0":
optional: true optional: true
@@ -4369,6 +4654,58 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
"@sentry/conventions@0.16.0": {}
"@sentry/core@10.69.0":
dependencies:
"@sentry/conventions": 0.16.0
"@sentry/node-core@10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))":
dependencies:
"@sentry/conventions": 0.16.0
"@sentry/core": 10.69.0
"@sentry/opentelemetry": 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
import-in-the-middle: 3.3.3
optionalDependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/core": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/instrumentation": 0.220.0(@opentelemetry/api@1.9.1)
"@opentelemetry/sdk-trace-base": 2.10.0(@opentelemetry/api@1.9.1)
"@sentry/node@10.69.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/instrumentation": 0.220.0(@opentelemetry/api@1.9.1)
"@opentelemetry/sdk-trace-base": 2.10.0(@opentelemetry/api@1.9.1)
"@sentry/conventions": 0.16.0
"@sentry/core": 10.69.0
"@sentry/node-core": 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
"@sentry/opentelemetry": 10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
"@sentry/server-utils": 10.69.0
import-in-the-middle: 3.3.3
transitivePeerDependencies:
- "@opentelemetry/core"
- "@opentelemetry/exporter-trace-otlp-http"
- supports-color
"@sentry/opentelemetry@10.69.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))":
dependencies:
"@opentelemetry/api": 1.9.1
"@opentelemetry/core": 2.10.0(@opentelemetry/api@1.9.1)
"@opentelemetry/sdk-trace-base": 2.10.0(@opentelemetry/api@1.9.1)
"@sentry/conventions": 0.16.0
"@sentry/core": 10.69.0
"@sentry/server-utils@10.69.0":
dependencies:
"@apm-js-collab/code-transformer-bundler-plugins": 0.7.4
"@apm-js-collab/tracing-hooks": 0.13.0
"@sentry/conventions": 0.16.0
"@sentry/core": 10.69.0
meriyah: 6.1.4
transitivePeerDependencies:
- supports-color
"@simple-libs/stream-utils@1.2.0": {} "@simple-libs/stream-utils@1.2.0": {}
"@sindresorhus/is@4.6.0": {} "@sindresorhus/is@4.6.0": {}
@@ -4494,6 +4831,8 @@ snapshots:
estree-walker: 3.0.3 estree-walker: 3.0.3
js-tokens: 10.0.0 js-tokens: 10.0.0
astring@1.9.0: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
@@ -4552,6 +4891,8 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
cjs-module-lexer@2.2.0: {}
clean-stack@5.3.0: clean-stack@5.3.0:
dependencies: dependencies:
escape-string-regexp: 5.0.0 escape-string-regexp: 5.0.0
@@ -4700,6 +5041,8 @@ snapshots:
es-module-lexer@1.7.0: {} es-module-lexer@1.7.0: {}
es-module-lexer@2.3.1: {}
esbuild@0.27.7: esbuild@0.27.7:
optionalDependencies: optionalDependencies:
"@esbuild/aix-ppc64": 0.27.7 "@esbuild/aix-ppc64": 0.27.7
@@ -4764,6 +5107,12 @@ snapshots:
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
esquery@1.7.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
estree-walker@3.0.3: estree-walker@3.0.3:
dependencies: dependencies:
"@types/estree": 1.0.9 "@types/estree": 1.0.9
@@ -4957,6 +5306,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
import-in-the-middle@3.3.3:
dependencies:
cjs-module-lexer: 2.2.0
es-module-lexer: 2.3.1
module-details-from-path: 1.0.4
import-meta-resolve@4.2.0: {} import-meta-resolve@4.2.0: {}
indent-string@5.0.0: {} indent-string@5.0.0: {}
@@ -5121,6 +5476,8 @@ snapshots:
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
meriyah@6.1.4: {}
micromatch@4.0.8: micromatch@4.0.8:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
@@ -5149,6 +5506,8 @@ snapshots:
pkg-types: 1.3.1 pkg-types: 1.3.1
ufo: 1.6.4 ufo: 1.6.4
module-details-from-path@1.0.4: {}
ms@2.1.3: {} ms@2.1.3: {}
mz@2.7.0: mz@2.7.0:
@@ -5398,6 +5757,13 @@ snapshots:
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-from@5.0.0: {} resolve-from@5.0.0: {}
@@ -5471,6 +5837,8 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
semifies@1.0.0: {}
semver-regex@4.0.5: {} semver-regex@4.0.5: {}
semver@7.8.5: {} semver@7.8.5: {}
+12 -2
View File
@@ -5,13 +5,23 @@ const repositoryUrl =
? `https://${encodeURIComponent(releaseUsername)}:${encodeURIComponent(releaseToken)}@git.mifi.dev/mifi/logger.git` ? `https://${encodeURIComponent(releaseUsername)}:${encodeURIComponent(releaseToken)}@git.mifi.dev/mifi/logger.git`
: "https://git.mifi.dev/mifi/logger.git"; : "https://git.mifi.dev/mifi/logger.git";
/**
* Default Angular preset rejects `feat!` / `fix!` headers entirely (no release).
* These parser options match Conventional Commits so `!` becomes a BREAKING CHANGE note.
*/
const conventionalCommitParserOpts = {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/,
noteKeywords: ["BREAKING CHANGE", "BREAKING-CHANGE"],
};
module.exports = { module.exports = {
branches: ["main"], branches: ["main"],
repositoryUrl, repositoryUrl,
tagFormat: "v${version}", tagFormat: "v${version}",
plugins: [ plugins: [
"@semantic-release/commit-analyzer", ["@semantic-release/commit-analyzer", { parserOpts: conventionalCommitParserOpts }],
"@semantic-release/release-notes-generator", ["@semantic-release/release-notes-generator", { parserOpts: conventionalCommitParserOpts }],
["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }], ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
"@semantic-release/npm", "@semantic-release/npm",
[ [
+33
View File
@@ -0,0 +1,33 @@
/**
* Severity threshold for log filtering, ordered from most to least verbose.
*
* | Level | Meaning |
* |-----------|----------------------------------------------|
* | `trace` | Extremely detailed diagnostics |
* | `debug` | Development diagnostics |
* | `info` | Routine operational messages |
* | `warn` | Unexpected but recoverable conditions |
* | `error` | Failures that need attention |
* | `silent` | Suppresses all console-bound output |
*
* A logger emits events at or above its configured threshold (e.g. `warn`
* allows `warn` and `error`).
*/
export enum LogLevel {
TRACE = "trace",
DEBUG = "debug",
INFO = "info",
WARN = "warn",
ERROR = "error",
SILENT = "silent",
}
/**
* Deployment stage used to pick a default log level when none is configured
* explicitly or via runtime overrides.
*/
export enum LoggerEnvironment {
DEVELOPMENT = "development",
STAGING = "staging",
PRODUCTION = "production",
}
+16 -2
View File
@@ -1,2 +1,16 @@
export * from "./logger.js"; /**
export * from "./types.js"; * @mifi/logger — intentional, namespaced logging for browser and Node.js.
*
* @packageDocumentation
*
* @example
* ```ts
* import { createLogger } from "@mifi/logger";
*
* const logger = createLogger({ environment: "development", namespace: "APP" });
* logger.info("started");
* ```
*/
export * from "./logger";
export * from "./sinks/console";
export * from "./types";
+247 -76
View File
@@ -1,20 +1,74 @@
import { createConsoleSink } from "./sinks/console";
import type { import type {
LogData, LogCallOptions,
LogEvent, LogEvent,
Logger, Logger,
LoggerEnvironment, LoggerEnvironment,
LoggerOptions, LoggerOptions,
LogLevel, LogLevel,
LogSink, LogSink,
} from "./types.js"; } from "./types";
/** Duck-type for sinks created by `createSentrySink` (avoids importing the sentry entry). */
type SentrySinkLike = LogSink & {
kind: "sentry";
options: { logs?: boolean; logLevel?: LogLevel };
};
function isSentrySinkLike(sink: LogSink): sink is SentrySinkLike {
return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry";
}
const LEVELS: readonly Exclude<LogLevel, "silent">[] = ["trace", "debug", "info", "warn", "error"]; const LEVELS: readonly Exclude<LogLevel, "silent">[] = ["trace", "debug", "info", "warn", "error"];
const LOG_CALL_OPTION_KEYS = new Set(["sentry", "suppressSentry"]);
/** Numeric rank for comparing levels; `silent` is above every emit level. */
const levelRank = (level: LogLevel): number => const levelRank = (level: LogLevel): number =>
level === "silent" ? Infinity : LEVELS.indexOf(level); level === "silent" ? Infinity : LEVELS.indexOf(level);
/**
* Default {@link LogLevel} for a {@link LoggerEnvironment} when no override is set.
*
* @param environment - Deployment stage.
* @returns `trace` (development), `warn` (staging), or `error` (production).
*/
const defaultLevel = (environment: LoggerEnvironment): LogLevel => const defaultLevel = (environment: LoggerEnvironment): LogLevel =>
environment === "development" ? "trace" : environment === "staging" ? "warn" : "error"; environment === "development" ? "trace" : environment === "staging" ? "warn" : "error";
/** Parses `debug` or `debug:WIDGET,API`, used by browser and container overrides. */ /**
* Default Sentry Logs threshold when `createSentrySink(..., { logs: true })` is
* used without an explicit `logLevel`.
*
* @param environment - Deployment stage.
* @returns `info` (staging) or `warn` (production). Development never sends.
*/
export const defaultSentryLogLevel = (environment: LoggerEnvironment): LogLevel =>
environment === "staging" ? "info" : "warn";
/**
* Parses a runtime logging override string used by browser session storage and
* container env composition.
*
* Accepted forms:
* - `"debug"` — set level only (all namespaces)
* - `"debug:WIDGET,API"` — set level and restrict to matching namespaces
* (exact match or descendant, e.g. `WIDGET` matches `WIDGET:Button`)
*
* @param value - Raw override string, or `null`/`undefined` when unset.
* @returns Parsed level and namespace filters, or `undefined` if invalid/empty.
*
* @example
* ```ts
* parseLoggingOverride("debug");
* // → { level: "debug", namespaces: [] }
*
* parseLoggingOverride("warn:WIDGET, API");
* // → { level: "warn", namespaces: ["WIDGET", "API"] }
*
* parseLoggingOverride("verbose");
* // → undefined
* ```
*/
export function parseLoggingOverride( export function parseLoggingOverride(
value: string | null | undefined, value: string | null | undefined,
): { level: LogLevel; namespaces: readonly string[] } | undefined { ): { level: LogLevel; namespaces: readonly string[] } | undefined {
@@ -31,6 +85,40 @@ export function parseLoggingOverride(
}; };
} }
/**
* Whether a value is a trailing {@link LogCallOptions} object (only known keys).
*
* @param value - Candidate last argument from a log call.
*/
export function isLogCallOptions(value: unknown): value is LogCallOptions {
if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Error)
return false;
const keys = Object.keys(value);
return keys.length > 0 && keys.every((key) => LOG_CALL_OPTION_KEYS.has(key));
}
/**
* Splits trailing {@link LogCallOptions} from log arguments.
*
* @param arguments_ - Raw call arguments.
* @returns Remaining arguments and parsed options (defaults to `{}`).
*/
function splitLogCallArguments(arguments_: readonly unknown[]): {
arguments: readonly unknown[];
options: LogCallOptions;
} {
if (arguments_.length === 0) return { arguments: arguments_, options: {} };
const last = arguments_[arguments_.length - 1];
if (!isLogCallOptions(last)) return { arguments: arguments_, options: {} };
return { arguments: arguments_.slice(0, -1), options: last };
}
/**
* Resolves session storage for browser overrides.
*
* @param provided - Optional storage; when omitted, uses `globalThis.sessionStorage`.
* @returns A `getItem`-compatible storage, or `undefined` if unavailable.
*/
function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "getItem"> | undefined { function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "getItem"> | undefined {
if (provided) return provided; if (provided) return provided;
try { try {
@@ -40,6 +128,12 @@ function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "get
} }
} }
/**
* Resolves the environment variable map for Node/container overrides.
*
* @param provided - Optional map; when omitted, uses `process.env` if present.
* @returns A string-keyed env map (may be empty).
*/
function resolveEnv( function resolveEnv(
provided?: Record<string, string | undefined>, provided?: Record<string, string | undefined>,
): Record<string, string | undefined> { ): Record<string, string | undefined> {
@@ -50,11 +144,26 @@ function resolveEnv(
); );
} }
/**
* Resolves whether the logger is running in a browser or Node-like runtime.
*
* @param provided - Explicit runtime; when omitted, inferred from `globalThis.window`.
* @returns `"browser"` if `window` is defined, otherwise `"node"`.
*/
function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" { function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" {
if (provided) return provided; if (provided) return provided;
return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser"; return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser";
} }
/**
* Whether a logger namespace matches any of the configured filters.
*
* Empty `filters` means “match all”. Otherwise the namespace must equal a
* filter or start with `filter:`.
*
* @param namespace - Logger namespace, if any.
* @param filters - Namespace prefixes from a runtime override.
*/
function namespaceMatches(namespace: string | undefined, filters: readonly string[]): boolean { function namespaceMatches(namespace: string | undefined, filters: readonly string[]): boolean {
return ( return (
filters.length === 0 || filters.length === 0 ||
@@ -65,13 +174,101 @@ function namespaceMatches(namespace: string | undefined, filters: readonly strin
); );
} }
/**
* Evaluates lazy log arguments: every argument after the first that is a
* zero-arg function is invoked; earlier arguments and non-functions are kept.
* Returned thenables are left as-is for {@link settleArguments}.
*
* @param arguments_ - Raw call arguments (message first, then optional data).
* @returns Arguments with lazy factories invoked (may still contain thenables).
*/
function evaluate(arguments_: readonly unknown[]): readonly unknown[] { function evaluate(arguments_: readonly unknown[]): readonly unknown[] {
return arguments_.map((argument, index) => return arguments_.map((argument, index) =>
index > 0 && typeof argument === "function" ? (argument as () => LogData)() : argument, index > 0 && typeof argument === "function" ? (argument as () => unknown)() : argument,
); );
} }
/** Creates an intentional, Console-shaped logger. Functions passed after the message are evaluated lazily. */ /** Duck-type check for Promise-like values. */
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
/**
* Settles top-level thenables in evaluated arguments.
* Fulfilled values replace the thenable; rejection reasons replace rejected ones.
*/
async function settleArguments(arguments_: readonly unknown[]): Promise<readonly unknown[]> {
const settled = await Promise.allSettled(
arguments_.map((argument) => (isThenable(argument) ? argument : Promise.resolve(argument))),
);
return settled.map((result) => (result.status === "fulfilled" ? result.value : result.reason));
}
function hasThenable(arguments_: readonly unknown[]): boolean {
return arguments_.some(isThenable);
}
/**
* Resolves Sentry sink options from `options.sentry` or a Sentry sink in `sinks`.
* Development always yields `undefined` (nothing is sent to Sentry).
*/
function resolveSentrySinkOptions(
options: LoggerOptions,
): { logs: boolean; logLevel: LogLevel } | undefined {
if (options.environment === "development") return undefined;
const candidate = options.sentry ? options.sentry : options.sinks?.find(isSentrySinkLike);
if (!candidate) return undefined;
if (isSentrySinkLike(candidate)) {
return {
logs: Boolean(candidate.options.logs),
logLevel: candidate.options.logLevel ?? defaultSentryLogLevel(options.environment),
};
}
return { logs: false, logLevel: defaultSentryLogLevel(options.environment) };
}
/**
* Creates an intentional, Console-shaped logger with namespacing, lazy
* arguments, environment-aware defaults, and optional Sentry integration.
*
* **Console level precedence** (highest wins):
* 1. `options.level`
* 2. `sessionStorage.showLoggingFor` (browser)
* 3. `MIFI_LOG_LEVEL` + optional `MIFI_LOG_NAMESPACES` (Node/containers)
* 4. Default for `options.environment`
*
* **Default sinks** (when `options.sinks` is omitted):
* - Always include a console sink except production **browser** with Sentry.
* - In staging/production with `options.sentry`, include that sink.
* - Development never attaches or sends to Sentry.
*
* Functions passed after the first argument are evaluated lazily — only when
* the event is emitted — so expensive snapshots stay cheap when suppressed.
* Async factories and top-level Promises are settled before sinks run
* (fire-and-forget); such events set {@link LogEvent.async}.
*
* @param options - Logger configuration. See {@link LoggerOptions}.
* @returns A {@link Logger} instance.
*
* @example
* ```ts
* import { createLogger } from "@mifi/logger";
*
* const logger = createLogger({
* environment: "production",
* namespace: "API",
* });
*
* logger.error("Request failed", error);
* logger.debug("skipped in production", () => hugeObject());
* logger.debug("response", async () => ({ data: await res.json() }));
* logger.warn("investigate", { id: 1 }, { sentry: true });
* ```
*/
export function createLogger(options: LoggerOptions): Logger { export function createLogger(options: LoggerOptions): Logger {
const namespace = const namespace =
typeof options.namespace === "string" typeof options.namespace === "string"
@@ -87,32 +284,53 @@ export function createLogger(options: LoggerOptions): Logger {
); );
const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment); const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment);
const runtime = resolveRuntime(options.runtime); const runtime = resolveRuntime(options.runtime);
const sentrySinkOptions = resolveSentrySinkOptions(options);
const sentryActive = sentrySinkOptions !== undefined;
const sinks: readonly LogSink[] = options.sinks ?? [ const sinks: readonly LogSink[] = options.sinks ?? [
...(runtime === "node" || options.environment !== "production" || !options.sentry ...(runtime === "node" || options.environment !== "production" || !options.sentry
? [createConsoleSink()] ? [createConsoleSink()]
: []), : []),
...(options.environment === "production" && options.sentry ? [options.sentry] : []), ...(options.sentry && options.environment !== "development" ? [options.sentry] : []),
]; ];
const enabled = (level: Exclude<LogLevel, "silent">) => const enabled = (level: Exclude<LogLevel, "silent">) =>
levelRank(level) >= levelRank(threshold) && levelRank(level) >= levelRank(threshold) &&
(!runtimeOverride?.namespaces.length || (!runtimeOverride?.namespaces.length ||
namespaceMatches(namespace, runtimeOverride.namespaces)); namespaceMatches(namespace, runtimeOverride.namespaces));
const emit = ( const deliver = (event: LogEvent) => {
level: Exclude<LogLevel, "silent">, sinks.forEach((sink) => sink.emit(event));
arguments_: readonly unknown[], };
sendToSentry = false, const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => {
) => { const split = splitLogCallArguments(arguments_);
const sendToConsole = enabled(level); const sendToConsole = enabled(level);
if (!sendToConsole && !sendToSentry) return; const sendToSentryIssue =
const event: LogEvent = { sentryActive && level === "error" && !split.options.suppressSentry;
const sendToSentryLogs =
sentryActive &&
level !== "error" &&
(Boolean(split.options.sentry) ||
(Boolean(sentrySinkOptions?.logs) &&
levelRank(level) >= levelRank(sentrySinkOptions.logLevel)));
if (!sendToConsole && !sendToSentryIssue && !sendToSentryLogs) return;
const timestamp = new Date();
const evaluated = evaluate(split.arguments);
const base = {
level, level,
namespace, namespace,
arguments: evaluate(arguments_), timestamp,
timestamp: new Date(), environment: options.environment,
sendToSentry, sendToSentryLogs,
sendToSentryIssue,
sendToConsole, sendToConsole,
}; };
sinks.forEach((sink) => sink.emit(event)); if (!hasThenable(evaluated)) {
deliver({ ...base, arguments: evaluated });
return;
}
void settleArguments(evaluated)
.then((settled) => deliver({ ...base, arguments: settled, async: true }))
.catch(() => {
/* allSettled absorbs rejections; defensive against unexpected throw */
});
}; };
const consoleMethod = ( const consoleMethod = (
method: keyof Console | "profile" | "profileEnd", method: keyof Console | "profile" | "profileEnd",
@@ -122,22 +340,14 @@ export function createLogger(options: LoggerOptions): Logger {
if (!enabled(level)) return; if (!enabled(level)) return;
const console_ = globalThis.console as Console & Record<string, unknown>; const console_ = globalThis.console as Console & Record<string, unknown>;
const fn = console_[method]; const fn = console_[method];
if (typeof fn === "function") if (typeof fn !== "function") return;
(fn as (...items: unknown[]) => void).apply( (fn as (...items: unknown[]) => void).apply(
console_, console_,
Array.from(prefix(namespace, evaluate(arguments_))), Array.from(prefix(namespace, evaluate(arguments_))),
); );
}; };
const api: Logger = { const api: Logger = {
namespace, namespace,
sentry: {
trace: (...items) => emit("trace", items, true),
debug: (...items) => emit("debug", items, true),
log: (...items) => emit("info", items, true),
info: (...items) => emit("info", items, true),
warn: (...items) => emit("warn", items, true),
error: (...items) => emit("error", items, true),
},
child: (child) => child: (child) =>
createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }), createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }),
trace: (...items) => emit("trace", items), trace: (...items) => emit("trace", items),
@@ -168,52 +378,13 @@ export function createLogger(options: LoggerOptions): Logger {
return api; return api;
} }
/**
* Prepends a bracketed namespace label to console utility method arguments.
*
* @param namespace - Colon-joined namespace, if any.
* @param arguments_ - Evaluated log arguments.
* @returns Arguments with `[A][B]` prefix when namespaced.
*/
function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] { function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] {
return namespace ? [`[${namespace.split(":").join("][")}]`, ...arguments_] : arguments_; return namespace ? [`[${namespace.split(":").join("][")}]`, ...arguments_] : arguments_;
} }
const ANSI_RESET = "\u001B[0m";
const ANSI_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
trace: "\u001B[90m",
debug: "\u001B[36m",
info: "\u001B[32m",
warn: "\u001B[33m",
error: "\u001B[31m",
};
const BROWSER_STYLE_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
trace: "color: #6b7280; font-weight: 600",
debug: "color: #0891b2; font-weight: 600",
info: "color: #15803d; font-weight: 600",
warn: "color: #a16207; font-weight: 700",
error: "color: #dc2626; font-weight: 700",
};
function isBrowser(): boolean {
return typeof (globalThis as { window?: unknown }).window !== "undefined";
}
function supportsAnsi(): boolean {
return Boolean(
(globalThis as { process?: { stdout?: { isTTY?: boolean } } }).process?.stdout?.isTTY,
);
}
/** Default readable console destination. Node's `console.error` writes to stderr. */
export function createConsoleSink(): LogSink {
return {
emit: (event) => {
if (!event.sendToConsole) return;
const method = event.level === "trace" ? "debug" : event.level;
const console_ = globalThis.console as Console & Record<string, unknown>;
const fn = console_[method];
if (typeof fn !== "function") return;
const label = event.namespace ? `[${event.namespace.split(":").join("][")}]` : "[LOG]";
const arguments_ = isBrowser()
? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments]
: supportsAnsi()
? [`${ANSI_BY_LEVEL[event.level]}${label}${ANSI_RESET}`, ...event.arguments]
: [label, ...event.arguments];
(fn as (...items: unknown[]) => void).apply(console_, arguments_);
},
};
}
+2 -37
View File
@@ -1,37 +1,2 @@
import type { LogEvent, LogSink } from "./types.js"; /** Public entry for `@mifi/logger/sentry`. Implementation: `src/sinks/sentry.ts`. */
export * from "./sinks/sentry";
export interface SentryScopeLike {
setLevel(level: string): void;
setTag(key: string, value: string): void;
setExtras(extras: Record<string, unknown>): void;
}
export interface SentryLike {
withScope(callback: (scope: SentryScopeLike) => void): void;
captureException(error: Error): void;
captureMessage(message: string, level: string): void;
}
/** Creates an optional Sentry adapter without adding a Sentry SDK dependency to this package. */
export function createSentrySink(sentry: SentryLike): LogSink {
return {
emit(event: LogEvent) {
if (event.level !== "error" && !event.sendToSentry) return;
const error = event.arguments.find((item): item is Error => item instanceof Error);
const extras = Object.fromEntries(
event.arguments
.filter((item) => item && typeof item === "object" && !(item instanceof Error))
.map((item, index) => [`context_${index}`, item]),
);
const message =
event.arguments.filter((item) => typeof item === "string").join(" ") ||
"Logger error";
sentry.withScope((scope) => {
scope.setLevel(event.level);
if (event.namespace) scope.setTag("logger.namespace", event.namespace);
scope.setExtras(extras);
if (error) sentry.captureException(error);
else sentry.captureMessage(message, event.level);
});
},
};
}
+71
View File
@@ -0,0 +1,71 @@
import type { LogLevel, LogSink } from "../types";
const ANSI_RESET = "\u001B[0m";
const ANSI_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
trace: "\u001B[90m",
debug: "\u001B[36m",
info: "\u001B[32m",
warn: "\u001B[33m",
error: "\u001B[31m",
};
const BROWSER_STYLE_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
trace: "color: #6b7280; font-weight: 600",
debug: "color: #0891b2; font-weight: 600",
info: "color: #15803d; font-weight: 600",
warn: "color: #a16207; font-weight: 700",
error: "color: #dc2626; font-weight: 700",
};
/** Whether the current global looks like a browser (`window` is defined). */
function isBrowser(): boolean {
return typeof (globalThis as { window?: unknown }).window !== "undefined";
}
/** Whether Node stdout is a TTY that can display ANSI colors. */
function supportsAnsi(): boolean {
return Boolean(
(globalThis as { process?: { stdout?: { isTTY?: boolean } } }).process?.stdout?.isTTY,
);
}
/**
* Default readable console {@link LogSink}.
*
* - Skips events where `sendToConsole` is `false`.
* - Maps `trace` to `console.debug`.
* - Prefixes a styled namespace label (`[A][B]` or `[LOG]`), plus `[async]`
* when {@link LogEvent.async} is set.
* - Uses CSS `%c` styling in browsers and ANSI colors on Node TTYs.
* - Node `error` level uses `console.error` (stderr).
*
* @returns A sink suitable as the default destination or for custom sink lists.
*
* @example
* ```ts
* const logger = createLogger({
* environment: "development",
* sinks: [createConsoleSink(), customSink],
* });
* ```
*/
export function createConsoleSink(): LogSink {
return {
emit: (event) => {
if (!event.sendToConsole) return;
const method = event.level === "trace" ? "debug" : event.level;
const console_ = globalThis.console as Console & Record<string, unknown>;
const fn = console_[method];
if (typeof fn !== "function") return;
const labelBase = event.namespace
? `[${event.namespace.split(":").join("][")}]`
: "[LOG]";
const label = event.async ? `${labelBase}[async]` : labelBase;
const arguments_ = isBrowser()
? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments]
: supportsAnsi()
? [`${ANSI_BY_LEVEL[event.level]}${label}${ANSI_RESET}`, ...event.arguments]
: [label, ...event.arguments];
(fn as (...items: unknown[]) => void).apply(console_, arguments_);
},
};
}
+226
View File
@@ -0,0 +1,226 @@
import type { LogEvent, LogLevel, LogSink } from "../types";
/**
* Sentry Issue severity levels (`@sentry/core` `SeverityLevel`).
* Note: Issues use `"warning"`; Logs use `"warn"`.
*/
export type SentrySeverityLevel = "fatal" | "error" | "warning" | "log" | "info" | "debug";
/**
* Options for {@link createSentrySink}.
*
* Sentry Logs are independent of the console level. Defaults when `logs` is
* enabled and `logLevel` is omitted: staging → `info`, production → `warn`.
* Development never sends (enforced by the logger).
*/
export interface SentrySinkOptions {
/**
* When `true`, non-error events at or above {@link SentrySinkOptions.logLevel}
* are written to `sentry.logger.*`. Errors still become Issues only.
*/
logs?: boolean;
/**
* Minimum level for automatic Sentry Logs delivery when {@link SentrySinkOptions.logs}
* is enabled. Per-call `{ sentry: true }` bypasses this threshold.
*/
logLevel?: LogLevel;
}
/**
* Minimal Sentry scope surface required by {@link createSentrySink}.
* Satisfied by `@sentry/browser`, `@sentry/node`, `@sentry/nextjs`, etc.
*
* Method parameter types are intentionally aligned with Sentry's public API
* (contravariance) so `typeof Sentry` assigns to {@link SentryLike} without adapters.
*/
export interface SentryScopeLike {
/**
* Sets the severity for the event about to be captured.
* @param level - Sentry Issue severity (`"warning"`, not logger `"warn"`).
*/
setLevel(level: SentrySeverityLevel): void;
/**
* Attaches a string tag to the event.
* @param key - Tag name (this package uses `"logger.namespace"`).
* @param value - Tag value.
*/
setTag(key: string, value: string): void;
/**
* Attaches structured extras derived from non-Error object arguments.
* @param extras - Key/value map of searchable context.
*/
setExtras(extras: Record<string, unknown>): void;
}
/**
* Minimal `sentry.logger` surface for Sentry Logs.
* Optional on {@link SentryLike}; Logs are skipped when absent.
*/
export interface SentryLoggerApiLike {
trace(message: string, attributes?: Record<string, string | number | boolean>): void;
debug(message: string, attributes?: Record<string, string | number | boolean>): void;
info(message: string, attributes?: Record<string, string | number | boolean>): void;
warn(message: string, attributes?: Record<string, string | number | boolean>): void;
error(message: string, attributes?: Record<string, string | number | boolean>): void;
}
/**
* Minimal Sentry client surface required by {@link createSentrySink}.
* Pass your app's Sentry SDK module; this package does not depend on Sentry.
*
* Structural typing matches official `@sentry/*` exports — no consumer adapter needed.
*
* @example
* ```ts
* import * as Sentry from "@sentry/nextjs";
* import { createSentrySink } from "@mifi/logger/sentry";
*
* const sink = createSentrySink(Sentry, { logs: true });
* ```
*/
export interface SentryLike {
/**
* Runs `callback` with an isolated scope for tagging/extras.
* @param callback - Receives a {@link SentryScopeLike} to configure.
*/
withScope(callback: (scope: SentryScopeLike) => void): void;
/**
* Captures an `Error` instance as an exception event.
* @param error - The first `Error` found in the log arguments, if any.
*/
captureException(error: Error): void;
/**
* Captures a string message when no `Error` is present in the arguments.
* Second argument matches Sentry's optional severity / capture-context slot.
* @param message - Joined string arguments, or a fallback.
* @param captureContext - Issue severity (use {@link toSentrySeverity} mapping).
*/
captureMessage(message: string, captureContext?: SentrySeverityLevel): void | string;
/** Structured Logs API. Required when using `{ logs: true }` or `{ sentry: true }`. */
logger?: SentryLoggerApiLike;
}
/** Sink returned by {@link createSentrySink}. */
export interface SentrySink extends LogSink {
readonly kind: "sentry";
readonly options: SentrySinkOptions;
}
/**
* Type guard for sinks created by {@link createSentrySink}.
*
* @param sink - Any {@link LogSink}.
*/
export function isSentrySink(sink: LogSink): sink is SentrySink {
return "kind" in sink && (sink as { kind?: unknown }).kind === "sentry";
}
/**
* Maps a logger level to a Sentry **Issue** {@link SentrySeverityLevel}.
*
* - `"warn"` → `"warning"` (Issues API; Logs keep `"warn"`)
* - `"trace"` → `"debug"` (closest Issue severity)
* - other levels map by name (`error`, `info`, `debug`)
*
* @param level - Logger emit level (never `silent`).
*/
export function toSentrySeverity(level: Exclude<LogLevel, "silent">): SentrySeverityLevel {
if (level === "warn") return "warning";
if (level === "trace") return "debug";
return level;
}
function isAttributeValue(value: unknown): value is string | number | boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
/**
* Builds a Sentry Logs message and flat primitive attributes from a log event.
*
* @param event - Emitted log event (call options already stripped from arguments).
*/
export function toSentryLogPayload(event: LogEvent): {
message: string;
attributes: Record<string, string | number | boolean>;
} {
const message =
event.arguments.filter((item) => typeof item === "string").join(" ") || "Log event";
const attributes: Record<string, string | number | boolean> = {};
if (event.namespace) attributes["logger.namespace"] = event.namespace;
if (event.async) attributes.async = true;
for (const item of event.arguments) {
if (!item || typeof item !== "object" || item instanceof Error || Array.isArray(item))
continue;
for (const [key, value] of Object.entries(item as Record<string, unknown>)) {
if (isAttributeValue(value)) attributes[key] = value;
}
}
return { message, attributes };
}
/**
* Creates a {@link LogSink} that forwards events to a Sentry-compatible SDK
* without adding a Sentry dependency to `@mifi/logger`.
*
* Capture rules (flags are set by {@link createLogger}):
* - `sendToSentryIssue` → `captureException` / `captureMessage` (errors only).
* - `sendToSentryLogs` → `sentry.logger[level]` (non-errors; never doubles an Issue).
*
* Enable automatic Logs with `{ logs: true }`. Override the threshold with
* `logLevel` (defaults: staging `info`, production `warn`). Force a single
* event with `{ sentry: true }` on the log call. Suppress an Issue with
* `{ suppressSentry: true }`.
*
* Official `@sentry/node`, `@sentry/browser`, and `@sentry/nextjs` modules are
* assignable to {@link SentryLike} directly — no adapter object required.
*
* @param sentry - Any object implementing {@link SentryLike} (typically the Sentry SDK).
* @param options - Optional Logs configuration.
* @returns A sink to pass as `sentry` or inside `sinks` on {@link createLogger}.
*
* @example
* ```ts
* import * as Sentry from "@sentry/node";
* import { createLogger } from "@mifi/logger";
* import { createSentrySink } from "@mifi/logger/sentry";
*
* const logger = createLogger({
* environment: "production",
* namespace: "API",
* sentry: createSentrySink(Sentry, { logs: true }),
* });
*
* logger.error("Request failed", new Error("offline"), { requestId: "req_1" });
* logger.warn("slow", { ms: 1200 }); // → Sentry Logs in production when logs enabled
* logger.info("probe", { id: 1 }, { sentry: true }); // forced Logs
* logger.error("expected", err, { suppressSentry: true });
* ```
*/
export function createSentrySink(sentry: SentryLike, options: SentrySinkOptions = {}): SentrySink {
return {
kind: "sentry",
options,
emit(event: LogEvent) {
if (event.environment === "development") return;
if (event.sendToSentryIssue) {
const error = event.arguments.find((item): item is Error => item instanceof Error);
const { message, attributes } = toSentryLogPayload(event);
const severity = toSentrySeverity(event.level);
sentry.withScope((scope) => {
scope.setLevel(severity);
if (event.namespace) scope.setTag("logger.namespace", event.namespace);
scope.setExtras(attributes);
if (error) sentry.captureException(error);
else sentry.captureMessage(message, severity);
});
return;
}
if (!event.sendToSentryLogs || !sentry.logger) return;
const method = event.level === "trace" ? "trace" : event.level;
const log = sentry.logger[method];
if (typeof log !== "function") return;
const { message, attributes } = toSentryLogPayload(event);
log.call(sentry.logger, message, attributes);
},
};
}
+258 -33
View File
@@ -1,77 +1,302 @@
/** A severity threshold, ordered from most to least verbose. */ import { LogLevel as LogLevelEnum, LoggerEnvironment as LoggerEnvironmentEnum } from "./constants";
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent";
export type LoggerEnvironment = "development" | "staging" | "production";
export type LogData = unknown | (() => unknown);
/** A structured event after its level policy has allowed it to be emitted. */ /**
export interface LogEvent { * Severity threshold for log filtering, ordered from most to least verbose.
level: Exclude<LogLevel, "silent">; *
namespace?: string; * | Level | Meaning |
arguments: readonly unknown[]; * |-----------|----------------------------------------------|
timestamp: Date; * | `trace` | Extremely detailed diagnostics |
/** Whether this event was explicitly selected for Sentry capture. */ * | `debug` | Development diagnostics |
sendToSentry: boolean; * | `info` | Routine operational messages |
/** Whether the default console sink should render this event. */ * | `warn` | Unexpected but recoverable conditions |
sendToConsole: boolean; * | `error` | Failures that need attention |
* | `silent` | Suppresses all console-bound output |
*
* A logger emits events at or above its configured threshold (e.g. `warn`
* allows `warn` and `error`).
*/
export type LogLevel = `${LogLevelEnum}`;
/**
* Deployment stage used to pick a default log level when none is configured
* explicitly or via runtime overrides.
*
* Defaults:
* - `development` → `trace`
* - `staging` → `warn`
* - `production` → `error`
*/
export type LoggerEnvironment = `${LoggerEnvironmentEnum}`;
/**
* A value passed as a log argument after the message.
*
* Prefer a zero-argument function for expensive payloads: it is only invoked
* when the event is actually emitted, so suppressed logs avoid the work.
* The factory may be `async` or otherwise return a `Promise`; top-level
* thenables are settled before sinks see the event (fire-and-forget).
*
* @example
* ```ts
* logger.debug("state", () => expensiveSnapshot());
* logger.debug("response", async () => ({ data: await res.json() }));
* ```
*/
export type LogData = unknown | (() => unknown | Promise<unknown>);
/**
* Per-call options for `trace` / `debug` / `log` / `info` / `warn` / `error`
* (and `assert`). Not supported on console utilities such as `group`.
*
* When the last argument is a plain object whose keys are only these option
* names, it is treated as options rather than log data.
*/
export interface LogCallOptions {
/**
* Force this event to Sentry Logs, ignoring the Sentry Logs threshold.
* No-op for `error` (errors become Issues, not Logs) and in development.
*/
sentry?: boolean;
/**
* Skip creating a Sentry Issue for an `error`-level event.
* No-op at lower levels.
*/
suppressSentry?: boolean;
} }
/** A destination for enabled log events. */ /**
* A structured log event after level and namespace policy have allowed it
* (or after it was selected for Sentry delivery).
*/
export interface LogEvent {
/** Severity of this event. Never `silent`. */
level: Exclude<LogLevel, "silent">;
/**
* Colon-joined namespace for this logger (e.g. `"WIDGET:Button"`).
* Omitted when the logger was created without a namespace.
*/
namespace?: string;
/**
* Evaluated arguments for the event (call options already stripped).
* Lazy factories have been invoked and top-level thenables settled;
* rejection reasons appear in place of rejected thenables.
*/
arguments: readonly unknown[];
/**
* Wall-clock time when the log call was made (before awaiting thenables).
*/
timestamp: Date;
/** Deployment stage from {@link LoggerOptions.environment}. */
environment: LoggerEnvironment;
/**
* `true` when the event should be written to Sentry Logs
* (`sentry.logger.*`), not as an Issue.
*/
sendToSentryLogs: boolean;
/**
* `true` when the event should be captured as a Sentry Issue
* (`captureException` / `captureMessage`).
*/
sendToSentryIssue: boolean;
/**
* `true` when the event passed the logger's level/namespace policy and
* should be rendered by console-oriented sinks.
*/
sendToConsole: boolean;
/**
* `true` when emit was deferred to settle top-level thenables.
* Omitted for fully synchronous emits. Console sinks render `[async]`.
*/
async?: boolean;
}
/**
* Destination that receives enabled {@link LogEvent}s.
*
* Provide custom sinks via {@link LoggerOptions.sinks}, or use
* {@link createConsoleSink} / `createSentrySink` from `@mifi/logger/sentry`.
*
* @example
* ```ts
* const sink: LogSink = {
* emit(event) {
* if (event.sendToConsole) analytics.track("log", event);
* },
* };
* ```
*/
export interface LogSink { export interface LogSink {
/**
* Handle a single log event.
* Implementations should respect `sendToConsole` / `sendToSentryLogs` /
* `sendToSentryIssue` as needed.
*/
emit(event: LogEvent): void; emit(event: LogEvent): void;
} }
/**
* Options for {@link createLogger}.
*
* Precedence for the effective console level (highest wins):
* 1. Explicit {@link LoggerOptions.level}
* 2. Browser `sessionStorage.showLoggingFor` (or {@link LoggerOptions.sessionStorage})
* 3. `MIFI_LOG_LEVEL` / `MIFI_LOG_NAMESPACES` from {@link LoggerOptions.env} or `process.env`
* 4. Default for {@link LoggerOptions.environment}
*/
export interface LoggerOptions { export interface LoggerOptions {
/**
* Deployment stage. Selects the default log level when no override applies.
* @see LoggerEnvironment
*/
environment: LoggerEnvironment; environment: LoggerEnvironment;
/**
* Optional namespace label(s). A string is used as-is; an array is joined
* with `:` (empty segments dropped). Shown in console output as
* `[A][B]` for `"A:B"`.
*
* @example
* ```ts
* createLogger({ environment: "development", namespace: "API" });
* createLogger({ environment: "development", namespace: ["WIDGET", "Button"] });
* ```
*/
namespace?: string | readonly string[]; namespace?: string | readonly string[];
/** Runtime used for the default destination policy. It is inferred when omitted. */ /**
* Runtime used for the default destination policy (console vs Sentry-only
* in production browsers). Inferred from `globalThis.window` when omitted.
*/
runtime?: "browser" | "node"; runtime?: "browser" | "node";
/** Hard override. It takes precedence over runtime environment defaults. */ /**
* Hard level override. Takes precedence over session storage, env vars,
* and environment defaults.
*/
level?: LogLevel; level?: LogLevel;
/** Browser-only session override source; defaults to global sessionStorage when available. */ /**
* Browser-only source for the `showLoggingFor` override key.
* Defaults to `globalThis.sessionStorage` when available.
*
* Expected value format matches {@link parseLoggingOverride}:
* `debug` or `debug:WIDGET,API`.
*/
sessionStorage?: Pick<Storage, "getItem">; sessionStorage?: Pick<Storage, "getItem">;
/** Node/container environment source; defaults to process.env when available. */ /**
* Node/container environment variable map. Defaults to `process.env`
* when available.
*
* Recognized keys:
* - `MIFI_LOG_LEVEL` — one of the {@link LogLevel} values
* - `MIFI_LOG_NAMESPACES` — optional comma-separated namespace filters
*/
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
/** Production error destination. Browser errors go only here; Node errors also go to stderr. */ /**
* Staging/production Sentry destination (typically from `createSentrySink`).
* Ignored in development (nothing is sent to Sentry).
*
* - Production **browser**: default sinks are Sentry only (no console).
* - Production **Node**: console (stderr for errors) and Sentry.
* - Staging: console and Sentry.
*
* Ignored when {@link LoggerOptions.sinks} is provided.
*/
sentry?: LogSink | false; sentry?: LogSink | false;
/** Complete destination override. When provided, `sentry` and default console behavior are not used. */ /**
* Complete destination list. When set, replaces the default console/Sentry
* policy entirely — {@link LoggerOptions.sentry} is not used.
*/
sinks?: readonly LogSink[]; sinks?: readonly LogSink[];
} }
/** Explicit Sentry reporting methods. These bypass the logger's normal level threshold. */ /**
export interface SentryLogger { * Console-shaped logger with namespaces, lazy arguments, child loggers,
trace(...arguments_: readonly unknown[]): void; * and optional Sentry Issues / Logs delivery.
debug(...arguments_: readonly unknown[]): void; *
log(...arguments_: readonly unknown[]): void; * Standard methods (`trace` … `error`) accept an optional trailing
info(...arguments_: readonly unknown[]): void; * {@link LogCallOptions} object. Console utility methods (`group`, `time`,
warn(...arguments_: readonly unknown[]): void; * `table`, …) are gated by the same console policy and call through to
error(...arguments_: readonly unknown[]): void; * `globalThis.console` when enabled — they do not accept {@link LogCallOptions}.
} *
* @example
* ```ts
* const logger = createLogger({ environment: "development", namespace: "WIDGET" });
* logger.info("ready");
* logger.child("Button").debug("clicked", { id: 1 });
* logger.warn("investigate", { orderId }, { sentry: true });
* logger.error("expected", err, { suppressSentry: true });
* ```
*/
export interface Logger { export interface Logger {
/**
* Colon-joined namespace for this instance, if any.
* Immutable; use {@link Logger.child} to nest further segments.
*/
readonly namespace?: string; readonly namespace?: string;
/** Sends selected events to a configured Sentry sink without changing the default policy for other logs. */ /**
readonly sentry: SentryLogger; * Returns a new logger that appends `namespace` to this logger's namespace.
*
* @param namespace - Segment to append (e.g. `"Button"` → `"WIDGET:Button"`).
* @returns A new {@link Logger} sharing the same options and sinks.
*
* @example
* ```ts
* const widget = createLogger({ environment: "development", namespace: "WIDGET" });
* const button = widget.child("Button"); // namespace === "WIDGET:Button"
* ```
*/
child(namespace: string): Logger; child(namespace: string): Logger;
/** Emit a `trace` event when the level policy allows it. */
trace(message?: unknown, data?: LogData, options?: LogCallOptions): void;
trace(...arguments_: readonly unknown[]): void; trace(...arguments_: readonly unknown[]): void;
/** Emit a `debug` event when the level policy allows it. */
debug(message?: unknown, data?: LogData, options?: LogCallOptions): void;
debug(...arguments_: readonly unknown[]): void; debug(...arguments_: readonly unknown[]): void;
/** Alias of {@link Logger.info}. */
log(message?: unknown, data?: LogData, options?: LogCallOptions): void;
log(...arguments_: readonly unknown[]): void; log(...arguments_: readonly unknown[]): void;
/** Emit an `info` event when the level policy allows it. */
info(message?: unknown, data?: LogData, options?: LogCallOptions): void;
info(...arguments_: readonly unknown[]): void; info(...arguments_: readonly unknown[]): void;
/** Emit a `warn` event when the level policy allows it. */
warn(message?: unknown, data?: LogData, options?: LogCallOptions): void;
warn(...arguments_: readonly unknown[]): void; warn(...arguments_: readonly unknown[]): void;
/** Emit an `error` event when the level policy allows it. */
error(message?: unknown, data?: LogData, options?: LogCallOptions): void;
error(...arguments_: readonly unknown[]): void; error(...arguments_: readonly unknown[]): void;
/**
* When `condition` is falsy, emits an `error` with `"Assertion failed"`
* followed by any extra arguments (optional trailing {@link LogCallOptions}).
*
* @param condition - Truthy values pass silently.
* @param arguments_ - Extra context included with the failure event.
*/
assert(condition: unknown, ...arguments_: readonly unknown[]): void; assert(condition: unknown, ...arguments_: readonly unknown[]): void;
/** Starts a console group when the level policy allows `info`. */
group(...arguments_: readonly unknown[]): void; group(...arguments_: readonly unknown[]): void;
/** Starts a collapsed console group when the level policy allows `info`. */
groupCollapsed(...arguments_: readonly unknown[]): void; groupCollapsed(...arguments_: readonly unknown[]): void;
/** Ends the current console group when the level policy allows `info`. */
groupEnd(): void; groupEnd(): void;
/** Logs an object with interactive inspection when the level policy allows `info`. */
dir(item?: unknown, options?: unknown): void; dir(item?: unknown, options?: unknown): void;
/** Logs XML/HTML as an interactive tree when the level policy allows `info`. */
dirxml(...arguments_: readonly unknown[]): void; dirxml(...arguments_: readonly unknown[]): void;
/** Renders tabular data when the level policy allows `info`. */
table(tabularData?: unknown, properties?: readonly string[]): void; table(tabularData?: unknown, properties?: readonly string[]): void;
/** Clears the console when the level policy allows `info`. */
clear(): void; clear(): void;
/** Increments a named counter when the level policy allows `info`. */
count(label?: string): void; count(label?: string): void;
/** Resets a named counter when the level policy allows `info`. */
countReset(label?: string): void; countReset(label?: string): void;
/** Starts a named timer when the level policy allows `info`. */
time(label?: string): void; time(label?: string): void;
/** Logs elapsed time for a named timer when the level policy allows `info`. */
timeLog(label?: string, ...arguments_: readonly unknown[]): void; timeLog(label?: string, ...arguments_: readonly unknown[]): void;
/** Stops a named timer and logs elapsed time when the level policy allows `info`. */
timeEnd(label?: string): void; timeEnd(label?: string): void;
/** Adds a timestamp marker to the performance timeline when allowed. */
timeStamp(label?: string): void; timeStamp(label?: string): void;
/** Starts a CPU profile (where supported) when the level policy allows `info`. */
profile(label?: string): void; profile(label?: string): void;
/** Ends a CPU profile (where supported) when the level policy allows `info`. */
profileEnd(label?: string): void; profileEnd(label?: string): void;
} }
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createConsoleSink } from "../src/index";
import type { LogEvent } from "../src/index";
function event(overrides: Partial<LogEvent> = {}): LogEvent {
return {
level: "debug",
namespace: "API",
arguments: ["hello"],
timestamp: new Date(),
environment: "development",
sendToSentryLogs: false,
sendToSentryIssue: false,
sendToConsole: true,
...overrides,
};
}
describe("createConsoleSink", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("appends [async] to the namespace label when event.async is set", () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => undefined);
createConsoleSink().emit(event({ async: true }));
expect(debug).toHaveBeenCalled();
const first = debug.mock.calls[0]?.[0];
expect(String(first)).toContain("[API][async]");
});
it("omits [async] for synchronous events", () => {
const debug = vi.spyOn(console, "debug").mockImplementation(() => undefined);
createConsoleSink().emit(event());
expect(debug).toHaveBeenCalled();
const first = debug.mock.calls[0]?.[0];
expect(String(first)).toContain("[API]");
expect(String(first)).not.toContain("[async]");
});
});
+199 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createLogger, parseLoggingOverride } from "../src/index.js"; import { createLogger, isLogCallOptions, parseLoggingOverride } from "../src/index";
import type { LogEvent, LogSink } from "../src/index.js"; import type { LogEvent, LogSink } from "../src/index";
import { createSentrySink } from "../src/sentry";
function sink(): { sink: LogSink; events: LogEvent[] } { function sink(): { sink: LogSink; events: LogEvent[] } {
const events: LogEvent[] = []; const events: LogEvent[] = [];
@@ -18,6 +19,17 @@ describe("parseLoggingOverride", () => {
}); });
}); });
describe("isLogCallOptions", () => {
it("accepts only known option keys", () => {
expect(isLogCallOptions({ sentry: true })).toBe(true);
expect(isLogCallOptions({ suppressSentry: true })).toBe(true);
expect(isLogCallOptions({ sentry: true, suppressSentry: false })).toBe(true);
expect(isLogCallOptions({ requestId: "req_1" })).toBe(false);
expect(isLogCallOptions({ sentry: true, requestId: "req_1" })).toBe(false);
expect(isLogCallOptions({})).toBe(false);
});
});
describe("createLogger", () => { describe("createLogger", () => {
it("uses environment defaults and never evaluates suppressed lazy data", () => { it("uses environment defaults and never evaluates suppressed lazy data", () => {
const destination = sink(); const destination = sink();
@@ -28,6 +40,43 @@ describe("createLogger", () => {
expect(expensive).toHaveBeenCalledTimes(1); expect(expensive).toHaveBeenCalledTimes(1);
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]?.arguments).toEqual(["kept", { huge: "snapshot" }]); expect(destination.events[0]?.arguments).toEqual(["kept", { huge: "snapshot" }]);
expect(destination.events[0]?.async).toBeUndefined();
});
it("never starts suppressed async lazy factories", () => {
const destination = sink();
const expensive = vi.fn(async () => ({ huge: "snapshot" }));
const logger = createLogger({ environment: "production", sinks: [destination.sink] });
logger.debug("ignored", expensive);
expect(expensive).not.toHaveBeenCalled();
expect(destination.events).toHaveLength(0);
});
it("settles async lazy factories before emitting and sets async", async () => {
const destination = sink();
const factory = vi.fn(async () => ({ data: 1 }));
const logger = createLogger({ environment: "development", sinks: [destination.sink] });
logger.debug("response", factory);
expect(factory).toHaveBeenCalledTimes(1);
expect(destination.events).toHaveLength(0);
await vi.waitFor(() => expect(destination.events).toHaveLength(1));
expect(destination.events[0]).toMatchObject({
arguments: ["response", { data: 1 }],
async: true,
});
});
it("unwraps direct Promise arguments and keeps rejection reasons", async () => {
const destination = sink();
const reason = new Error("boom");
const logger = createLogger({ environment: "development", sinks: [destination.sink] });
logger.error("failed", { id: 1 }, Promise.reject(reason));
expect(destination.events).toHaveLength(0);
await vi.waitFor(() => expect(destination.events).toHaveLength(1));
expect(destination.events[0]).toMatchObject({
arguments: ["failed", { id: 1 }, reason],
async: true,
});
}); });
it("applies session namespace filtering to namespace descendants", () => { it("applies session namespace filtering to namespace descendants", () => {
@@ -74,11 +123,16 @@ describe("createLogger", () => {
}); });
logger.error("captured"); logger.error("captured");
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({
sendToSentryIssue: true,
sendToSentryLogs: false,
sendToConsole: true,
});
expect(consoleError).not.toHaveBeenCalled(); expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore(); consoleError.mockRestore();
}); });
it("sends explicitly selected production events to Sentry without writing them to the console", () => { it("forces selected events to Sentry Logs without writing them to the console", () => {
const destination = sink(); const destination = sink();
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
const logger = createLogger({ const logger = createLogger({
@@ -86,18 +140,89 @@ describe("createLogger", () => {
runtime: "browser", runtime: "browser",
sentry: destination.sink, sentry: destination.sink,
}); });
logger.sentry.info("Cache warmed"); logger.info("Cache warmed", { sentry: true });
expect(destination.events).toHaveLength(1); expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({ expect(destination.events[0]).toMatchObject({
level: "info", level: "info",
arguments: ["Cache warmed"], arguments: ["Cache warmed"],
sendToSentry: true, sendToSentryLogs: true,
sendToSentryIssue: false,
sendToConsole: false, sendToConsole: false,
}); });
expect(consoleInfo).not.toHaveBeenCalled(); expect(consoleInfo).not.toHaveBeenCalled();
consoleInfo.mockRestore(); consoleInfo.mockRestore();
}); });
it("strips trailing call options from event arguments and supports data plus options", () => {
const destination = sink();
const logger = createLogger({
environment: "production",
runtime: "browser",
sentry: destination.sink,
});
logger.warn("slow", { ms: 1200 }, { sentry: true });
expect(destination.events[0]).toMatchObject({
level: "warn",
arguments: ["slow", { ms: 1200 }],
sendToSentryLogs: true,
sendToConsole: false,
});
});
it("suppresses Sentry Issues when suppressSentry is set", () => {
const destination = sink();
const logger = createLogger({
environment: "production",
runtime: "browser",
sentry: destination.sink,
});
logger.error("expected", new Error("nope"), { suppressSentry: true });
expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({
sendToSentryIssue: false,
sendToSentryLogs: false,
arguments: ["expected", expect.any(Error)],
});
});
it("never marks events for Sentry in development", () => {
const destination = sink();
const logger = createLogger({
environment: "development",
sinks: [
destination.sink,
createSentrySink(
{
withScope: (callback) =>
callback({
setLevel: () => undefined,
setTag: () => undefined,
setExtras: () => undefined,
}),
captureException: vi.fn(),
captureMessage: vi.fn(),
logger: {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
{ logs: true },
),
],
});
logger.error("local only");
logger.info("probe", { sentry: true });
expect(destination.events).toHaveLength(2);
expect(
destination.events.every(
(event) => !event.sendToSentryIssue && !event.sendToSentryLogs,
),
).toBe(true);
});
it("keeps production Node errors on stderr as well as sending them to Sentry", () => { it("keeps production Node errors on stderr as well as sending them to Sentry", () => {
const destination = sink(); const destination = sink();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -111,4 +236,73 @@ describe("createLogger", () => {
expect(consoleError).toHaveBeenCalledTimes(1); expect(consoleError).toHaveBeenCalledTimes(1);
consoleError.mockRestore(); consoleError.mockRestore();
}); });
it("mirrors warn to Sentry Logs in production when logs are enabled on the sink", () => {
const destination = sink();
const logger = createLogger({
environment: "production",
runtime: "browser",
sinks: [
destination.sink,
createSentrySink(
{
withScope: () => undefined,
captureException: vi.fn(),
captureMessage: vi.fn(),
logger: {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
{ logs: true },
),
],
});
logger.warn("noisy");
logger.info("below default production logs threshold");
expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({
level: "warn",
sendToSentryLogs: true,
sendToConsole: false,
});
});
it("mirrors info to Sentry Logs in staging when logs are enabled", () => {
const destination = sink();
const logger = createLogger({
environment: "staging",
runtime: "browser",
sinks: [
destination.sink,
createSentrySink(
{
withScope: () => undefined,
captureException: vi.fn(),
captureMessage: vi.fn(),
logger: {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
{ logs: true },
),
],
});
logger.info("visible in staging logs");
logger.debug("still below staging logs threshold");
expect(destination.events.map((event) => event.arguments[0])).toEqual([
"visible in staging logs",
]);
expect(destination.events[0]).toMatchObject({
sendToSentryLogs: true,
sendToConsole: false,
});
});
}); });
+30
View File
@@ -0,0 +1,30 @@
/**
* Compile-time regression: official Sentry SDKs must assign to {@link SentryLike}
* without casts or consumer adapters. Checked by `pnpm check` (`tsc --noEmit`).
*/
import * as Sentry from "@sentry/node";
import { createSentrySink, type SentryLike } from "../src/sentry";
const _sdk: SentryLike = Sentry;
createSentrySink(Sentry);
createSentrySink(Sentry, { logs: true });
const stub: SentryLike = {
withScope: (callback) =>
callback({
setLevel: () => undefined,
setTag: () => undefined,
setExtras: () => undefined,
}),
captureException: () => undefined,
captureMessage: () => undefined,
logger: {
trace: () => undefined,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
};
createSentrySink(stub);
createSentrySink(stub, { logs: true });
+178 -12
View File
@@ -1,28 +1,194 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createLogger } from "../src/index.js"; import { createLogger, type LogEvent } from "../src/index";
import { createSentrySink } from "../src/sentry.js"; import { createSentrySink, toSentryLogPayload, toSentrySeverity } from "../src/sentry";
describe("createSentrySink", () => { function createFakeSentry() {
it("captures errors and explicitly selected events with namespace and structured context", () => { const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() };
const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() }; return {
const sentry = { scope,
sentry: {
withScope: (callback: (value: typeof scope) => void) => callback(scope), withScope: (callback: (value: typeof scope) => void) => callback(scope),
captureException: vi.fn(), captureException: vi.fn(),
captureMessage: vi.fn(), captureMessage: vi.fn(),
}; logger: {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
},
};
}
function issueEvent(overrides: Partial<LogEvent> = {}): LogEvent {
return {
level: "error",
arguments: ["message"],
timestamp: new Date(),
environment: "production",
sendToSentryLogs: false,
sendToSentryIssue: true,
sendToConsole: false,
...overrides,
};
}
describe("toSentrySeverity", () => {
it("maps logger levels to Sentry Issue severities", () => {
expect(toSentrySeverity("warn")).toBe("warning");
expect(toSentrySeverity("trace")).toBe("debug");
expect(toSentrySeverity("error")).toBe("error");
expect(toSentrySeverity("info")).toBe("info");
expect(toSentrySeverity("debug")).toBe("debug");
});
});
describe("toSentryLogPayload", () => {
it("joins string messages and flattens primitive object attributes", () => {
expect(
toSentryLogPayload({
level: "info",
namespace: "API",
arguments: ["Cache warmed", { entries: 42, ok: true, nested: { a: 1 } }],
timestamp: new Date(),
environment: "production",
sendToSentryLogs: true,
sendToSentryIssue: false,
sendToConsole: false,
}),
).toEqual({
message: "Cache warmed",
attributes: {
"logger.namespace": "API",
entries: 42,
ok: true,
},
});
});
it("includes async when the event was deferred for thenables", () => {
expect(
toSentryLogPayload({
level: "debug",
arguments: ["response"],
timestamp: new Date(),
environment: "development",
sendToSentryLogs: true,
sendToSentryIssue: false,
sendToConsole: true,
async: true,
}).attributes,
).toEqual({ async: true });
});
});
describe("createSentrySink", () => {
it("captures errors as Issues with namespace and structured attributes", () => {
const { scope, sentry } = createFakeSentry();
const logger = createLogger({ const logger = createLogger({
environment: "production", environment: "production",
namespace: "API", namespace: "API",
sinks: [createSentrySink(sentry)], sinks: [createSentrySink(sentry)],
}); });
const error = new Error("offline"); const error = new Error("offline");
logger.warn("ignored"); logger.warn("ignored without logs");
logger.sentry.info("Cache warmed");
logger.error("Request failed", error, { requestId: "req_1" }); logger.error("Request failed", error, { requestId: "req_1" });
expect(sentry.captureMessage).toHaveBeenCalledWith("Cache warmed", "info");
expect(sentry.captureException).toHaveBeenCalledWith(error); expect(sentry.captureException).toHaveBeenCalledWith(error);
expect(scope.setLevel).toHaveBeenCalledWith("info"); expect(sentry.captureMessage).not.toHaveBeenCalled();
expect(sentry.logger.info).not.toHaveBeenCalled();
expect(scope.setLevel).toHaveBeenCalledWith("error");
expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API"); expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API");
expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } }); expect(scope.setExtras).toHaveBeenCalledWith({
"logger.namespace": "API",
requestId: "req_1",
});
});
it("writes non-error events to Sentry Logs when enabled, not as Issues", () => {
const { sentry } = createFakeSentry();
const logger = createLogger({
environment: "production",
namespace: "API",
sinks: [createSentrySink(sentry, { logs: true })],
});
logger.warn("slow", { ms: 1200 });
logger.info("forced", { id: 1 }, { sentry: true });
expect(sentry.captureMessage).not.toHaveBeenCalled();
expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.warn).toHaveBeenCalledWith("slow", {
"logger.namespace": "API",
ms: 1200,
});
expect(sentry.logger.info).toHaveBeenCalledWith("forced", {
"logger.namespace": "API",
id: 1,
});
});
it("does not create an Issue when suppressSentry is set", () => {
const { sentry } = createFakeSentry();
const logger = createLogger({
environment: "production",
runtime: "node",
sinks: [createSentrySink(sentry, { logs: true })],
});
logger.error("expected", new Error("nope"), { suppressSentry: true });
expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.error).not.toHaveBeenCalled();
});
it("never sends in development", () => {
const { sentry } = createFakeSentry();
const logger = createLogger({
environment: "development",
sinks: [createSentrySink(sentry, { logs: true })],
});
logger.error("local", new Error("x"));
logger.warn("local warn", { sentry: true });
expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.logger.warn).not.toHaveBeenCalled();
});
it("maps warn → warning on the Issues path and keeps warn on the Logs path", () => {
const { scope, sentry } = createFakeSentry();
const sink = createSentrySink(sentry, { logs: true });
sink.emit(
issueEvent({
level: "warn",
arguments: ["degraded"],
}),
);
expect(scope.setLevel).toHaveBeenCalledWith("warning");
expect(sentry.captureMessage).toHaveBeenCalledWith("degraded", "warning");
expect(sentry.logger.warn).not.toHaveBeenCalled();
sink.emit({
level: "warn",
namespace: "API",
arguments: ["slow", { ms: 10 }],
timestamp: new Date(),
environment: "production",
sendToSentryLogs: true,
sendToSentryIssue: false,
sendToConsole: false,
});
expect(sentry.logger.warn).toHaveBeenCalledWith("slow", {
"logger.namespace": "API",
ms: 10,
});
});
it("maps error Issue severity as error", () => {
const { scope, sentry } = createFakeSentry();
createSentrySink(sentry).emit(
issueEvent({
level: "error",
arguments: ["failed"],
}),
);
expect(scope.setLevel).toHaveBeenCalledWith("error");
expect(sentry.captureMessage).toHaveBeenCalledWith("failed", "error");
}); });
}); });
+3 -4
View File
@@ -1,13 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2024", "target": "ES2024",
"module": "NodeNext", "module": "ESNext",
"moduleResolution": "NodeNext", "moduleResolution": "bundler",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true
"ignoreDeprecations": "6.0"
}, },
"include": ["src", "test", "tsup.config.ts", "vitest.config.ts"] "include": ["src", "test", "tsup.config.ts", "vitest.config.ts"]
} }
+7 -1
View File
@@ -3,7 +3,13 @@ import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: { index: "src/index.ts", sentry: "src/sentry.ts" }, entry: { index: "src/index.ts", sentry: "src/sentry.ts" },
format: ["esm", "cjs"], format: ["esm", "cjs"],
dts: true, // tsup injects baseUrl for DTS; TS 6 treats that as an error until tsup stops
// (https://github.com/egoist/tsup/issues/1388)
dts: {
compilerOptions: {
ignoreDeprecations: "6.0",
},
},
clean: true, clean: true,
target: "es2022", target: "es2022",
sourcemap: true, sourcemap: true,