Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19de8db119 | ||
|
|
9bcf28c9dc | ||
|
|
234226e2bb | ||
|
|
6bee3a801d |
+15
-2
@@ -1,9 +1,22 @@
|
|||||||
# [0.10.0](https://git.mifi.dev/mifi/logger/compare/v0.9.3...v0.10.0) (2026-08-05)
|
# [1.0.0](https://git.mifi.dev/mifi/logger/compare/v0.10.0...v1.0.0) (2026-08-07)
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
* **logger:** add explicit Sentry event channel ([44f4716](https://git.mifi.dev/mifi/logger/commit/44f471653e8d4045e87886da1e1bbe04edbd60af))
|
* 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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,41 +5,201 @@ 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**. Provide the SDK your application already uses via `@mifi/logger/sentry`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 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`, `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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
@@ -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
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mifi/logger",
|
"name": "@mifi/logger",
|
||||||
"version": "0.10.0",
|
"version": "1.0.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",
|
||||||
|
|||||||
+12
-2
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
+16
-2
@@ -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";
|
||||||
|
|||||||
+198
-66
@@ -1,4 +1,6 @@
|
|||||||
|
import { createConsoleSink } from "./sinks/console";
|
||||||
import type {
|
import type {
|
||||||
|
LogCallOptions,
|
||||||
LogData,
|
LogData,
|
||||||
LogEvent,
|
LogEvent,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -6,15 +8,68 @@ import type {
|
|||||||
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 +86,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 +129,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 +145,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 +175,73 @@ 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.
|
||||||
|
*
|
||||||
|
* @param arguments_ - Raw call arguments (message first, then optional data).
|
||||||
|
* @returns Arguments with lazy factories resolved to their return values.
|
||||||
|
*/
|
||||||
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 () => LogData)() : argument,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates an intentional, Console-shaped logger. Functions passed after the message are evaluated lazily. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @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.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,29 +257,38 @@ 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 emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => {
|
||||||
level: Exclude<LogLevel, "silent">,
|
const split = splitLogCallArguments(arguments_);
|
||||||
arguments_: readonly unknown[],
|
|
||||||
sendToSentry = false,
|
|
||||||
) => {
|
|
||||||
const sendToConsole = enabled(level);
|
const sendToConsole = enabled(level);
|
||||||
if (!sendToConsole && !sendToSentry) return;
|
const sendToSentryIssue =
|
||||||
|
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 event: LogEvent = {
|
const event: LogEvent = {
|
||||||
level,
|
level,
|
||||||
namespace,
|
namespace,
|
||||||
arguments: evaluate(arguments_),
|
arguments: evaluate(split.arguments),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
sendToSentry,
|
environment: options.environment,
|
||||||
|
sendToSentryLogs,
|
||||||
|
sendToSentryIssue,
|
||||||
sendToConsole,
|
sendToConsole,
|
||||||
};
|
};
|
||||||
sinks.forEach((sink) => sink.emit(event));
|
sinks.forEach((sink) => sink.emit(event));
|
||||||
@@ -130,14 +309,6 @@ export function createLogger(options: LoggerOptions): Logger {
|
|||||||
};
|
};
|
||||||
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 +339,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
@@ -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);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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]`).
|
||||||
|
* - 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 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_);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { LogEvent, LogLevel, LogSink } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface SentryScopeLike {
|
||||||
|
/**
|
||||||
|
* Sets the severity for the event about to be captured.
|
||||||
|
* @param level - Logger level string (e.g. `"error"`, `"info"`).
|
||||||
|
*/
|
||||||
|
setLevel(level: string): 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.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
* @param message - Joined string arguments, or a fallback.
|
||||||
|
* @param level - Event severity.
|
||||||
|
*/
|
||||||
|
captureMessage(message: string, level: string): void;
|
||||||
|
/** 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 }`.
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
sentry.withScope((scope) => {
|
||||||
|
scope.setLevel(event.level);
|
||||||
|
if (event.namespace) scope.setTag("logger.namespace", event.namespace);
|
||||||
|
scope.setExtras(attributes);
|
||||||
|
if (error) sentry.captureException(error);
|
||||||
|
else sentry.captureMessage(message, event.level);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+236
-24
@@ -1,77 +1,289 @@
|
|||||||
/** A severity threshold, ordered from most to least verbose. */
|
/**
|
||||||
|
* 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 type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent";
|
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = "development" | "staging" | "production";
|
export type LoggerEnvironment = "development" | "staging" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* logger.debug("state", () => expensiveSnapshot());
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export type LogData = unknown | (() => unknown);
|
export type LogData = unknown | (() => unknown);
|
||||||
|
|
||||||
/** A structured event after its level policy has allowed it to be emitted. */
|
/**
|
||||||
|
* 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 structured log event after level and namespace policy have allowed it
|
||||||
|
* (or after it was selected for Sentry delivery).
|
||||||
|
*/
|
||||||
export interface LogEvent {
|
export interface LogEvent {
|
||||||
|
/** Severity of this event. Never `silent`. */
|
||||||
level: Exclude<LogLevel, "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;
|
namespace?: string;
|
||||||
|
/**
|
||||||
|
* Evaluated arguments for the event (call options already stripped).
|
||||||
|
* Lazy `() => unknown` functions have already been invoked.
|
||||||
|
*/
|
||||||
arguments: readonly unknown[];
|
arguments: readonly unknown[];
|
||||||
|
/** Wall-clock time when the event was created. */
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
/** Whether this event was explicitly selected for Sentry capture. */
|
/** Deployment stage from {@link LoggerOptions.environment}. */
|
||||||
sendToSentry: boolean;
|
environment: LoggerEnvironment;
|
||||||
/** Whether the default console sink should render this event. */
|
/**
|
||||||
|
* `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;
|
sendToConsole: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A destination for enabled log events. */
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-5
@@ -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();
|
||||||
@@ -74,11 +86,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 +103,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 +199,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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+96
-10
@@ -1,28 +1,114 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createLogger } from "../src/index.js";
|
import { createLogger } from "../src/index";
|
||||||
import { createSentrySink } from "../src/sentry.js";
|
import { createSentrySink, toSentryLogPayload } 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() };
|
||||||
const sentry = {
|
return {
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"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,
|
||||||
|
|||||||
Reference in New Issue
Block a user