@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.artifacts/
|
||||||
|
.npmrc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "singleQuote": false, "tabWidth": 4, "trailingComma": "all", "printWidth": 100 }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: tag
|
||||||
|
ref: refs/tags/v*
|
||||||
|
|
||||||
|
steps:
|
||||||
|
verify:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- corepack prepare pnpm@11.0.0 --activate
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
- pnpm format:check
|
||||||
|
- pnpm lint
|
||||||
|
- pnpm check
|
||||||
|
- pnpm test
|
||||||
|
- pnpm build
|
||||||
|
- pnpm pack:check
|
||||||
|
|
||||||
|
notify-ci-failure:
|
||||||
|
image: curlimages/curl:8.14.1
|
||||||
|
depends_on: [verify]
|
||||||
|
environment:
|
||||||
|
MATTERMOST_BOT_ACCESS_TOKEN:
|
||||||
|
from_secret: mattermost_bot_access_token
|
||||||
|
MATTERMOST_CHANNEL_ID:
|
||||||
|
from_secret: mattermost_tests_channel_id
|
||||||
|
MATTERMOST_POST_API_URL:
|
||||||
|
from_secret: mattermost_post_api_url
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] CI 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-ci-success:
|
||||||
|
image: curlimages/curl:8.14.1
|
||||||
|
depends_on: [verify]
|
||||||
|
environment:
|
||||||
|
MATTERMOST_BOT_ACCESS_TOKEN:
|
||||||
|
from_secret: mattermost_bot_access_token
|
||||||
|
MATTERMOST_CHANNEL_ID:
|
||||||
|
from_secret: mattermost_tests_channel_id
|
||||||
|
MATTERMOST_POST_API_URL:
|
||||||
|
from_secret: mattermost_post_api_url
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] CI 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]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
ref: refs/tags/v*
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- ci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
publish:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
environment:
|
||||||
|
GITEA_PACKAGE_TOKEN:
|
||||||
|
from_secret: gitea_package_token
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- corepack prepare pnpm@11.0.0 --activate
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
- pnpm check
|
||||||
|
- pnpm test
|
||||||
|
- pnpm build
|
||||||
|
- test "v$(node -p "require('./package.json').version")" = "$CI_COMMIT_TAG"
|
||||||
|
- npm config set @mifi:registry https://git.mifi.dev/api/packages/mifi/npm/
|
||||||
|
- npm config set -- //git.mifi.dev/api/packages/mifi/npm/:_authToken "$GITEA_PACKAGE_TOKEN"
|
||||||
|
- pnpm publish --no-git-checks
|
||||||
|
|
||||||
|
notify-publish-failure:
|
||||||
|
image: curlimages/curl:8.14.1
|
||||||
|
depends_on: [publish]
|
||||||
|
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 publish 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-publish-success:
|
||||||
|
image: curlimages/curl:8.14.1
|
||||||
|
depends_on: [publish]
|
||||||
|
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 publish 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]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# @mifi/logger
|
||||||
|
|
||||||
|
Intentional, namespaced logging for TypeScript browser and Node.js applications.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createLogger } from "@mifi/logger";
|
||||||
|
|
||||||
|
const logger = createLogger({ environment: "production" });
|
||||||
|
logger.child("WIDGET").debug("Rendered", () => ({ expensive: "only evaluated when shown" }));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
debug
|
||||||
|
debug:WIDGET,API
|
||||||
|
```
|
||||||
|
|
||||||
|
Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level` options take precedence.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { createLogger } from "@mifi/logger";
|
||||||
|
import { createSentrySink } from "@mifi/logger/sentry";
|
||||||
|
|
||||||
|
const logger = createLogger({ environment: "production", sentry: createSentrySink(Sentry) });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Woodpecker verifies pull requests, `main`, and version tags, and reports CI to Mattermost. To publish, push a tag matching the package version (for example, `v0.9.1`). The tag build verifies the package before publishing it to the private `@mifi` registry and reports the result to Mattermost. It uses the existing global `gitea_package_token`, `mattermost_bot_access_token`, `mattermost_tests_channel_id`, `mattermost_pushes_channel_id`, and `mattermost_post_api_url` secrets.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@mifi/logger",
|
||||||
|
"version": "0.9.1",
|
||||||
|
"description": "Intentional, namespaced logging for browser and Node.js TypeScript applications.",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"packageManager": "pnpm@11.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"./sentry": {
|
||||||
|
"types": "./dist/sentry.d.ts",
|
||||||
|
"import": "./dist/sentry.js",
|
||||||
|
"require": "./dist/sentry.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.mifi.dev/api/packages/mifi/npm/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"lint": "oxlint src test",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"pack:check": "pnpm pack --pack-destination .artifacts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
|
"oxlint": "^1.0.0",
|
||||||
|
"prettier": "^3.5.0",
|
||||||
|
"tsup": "^8.4.0",
|
||||||
|
"typescript": "^6.0.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3004
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./logger.js";
|
||||||
|
export * from "./types.js";
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
import type {
|
||||||
|
LogData,
|
||||||
|
LogEvent,
|
||||||
|
Logger,
|
||||||
|
LoggerEnvironment,
|
||||||
|
LoggerOptions,
|
||||||
|
LogLevel,
|
||||||
|
LogSink,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const LEVELS: readonly Exclude<LogLevel, "silent">[] = ["trace", "debug", "info", "warn", "error"];
|
||||||
|
const levelRank = (level: LogLevel): number =>
|
||||||
|
level === "silent" ? Infinity : LEVELS.indexOf(level);
|
||||||
|
const defaultLevel = (environment: LoggerEnvironment): LogLevel =>
|
||||||
|
environment === "development" ? "trace" : environment === "staging" ? "warn" : "error";
|
||||||
|
|
||||||
|
/** Parses `debug` or `debug:WIDGET,API`, used by browser and container overrides. */
|
||||||
|
export function parseLoggingOverride(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): { level: LogLevel; namespaces: readonly string[] } | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const [rawLevel, rawNamespaces = ""] = value.trim().split(":", 2);
|
||||||
|
if (!(["trace", "debug", "info", "warn", "error", "silent"] as string[]).includes(rawLevel))
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
level: rawLevel as LogLevel,
|
||||||
|
namespaces: rawNamespaces
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "getItem"> | undefined {
|
||||||
|
if (provided) return provided;
|
||||||
|
try {
|
||||||
|
return globalThis.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEnv(
|
||||||
|
provided?: Record<string, string | undefined>,
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
if (provided) return provided;
|
||||||
|
return (
|
||||||
|
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" {
|
||||||
|
if (provided) return provided;
|
||||||
|
return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser";
|
||||||
|
}
|
||||||
|
|
||||||
|
function namespaceMatches(namespace: string | undefined, filters: readonly string[]): boolean {
|
||||||
|
return (
|
||||||
|
filters.length === 0 ||
|
||||||
|
Boolean(
|
||||||
|
namespace &&
|
||||||
|
filters.some((filter) => namespace === filter || namespace.startsWith(`${filter}:`)),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(arguments_: readonly unknown[]): readonly unknown[] {
|
||||||
|
return arguments_.map((argument, index) =>
|
||||||
|
index > 0 && typeof argument === "function" ? (argument as () => LogData)() : argument,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an intentional, Console-shaped logger. Functions passed after the message are evaluated lazily. */
|
||||||
|
export function createLogger(options: LoggerOptions): Logger {
|
||||||
|
const namespace =
|
||||||
|
typeof options.namespace === "string"
|
||||||
|
? options.namespace
|
||||||
|
: options.namespace?.filter(Boolean).join(":");
|
||||||
|
const env = resolveEnv(options.env);
|
||||||
|
const runtimeOverride =
|
||||||
|
parseLoggingOverride(resolveStorage(options.sessionStorage)?.getItem("showLoggingFor")) ??
|
||||||
|
parseLoggingOverride(
|
||||||
|
env.MIFI_LOG_LEVEL
|
||||||
|
? `${env.MIFI_LOG_LEVEL}${env.MIFI_LOG_NAMESPACES ? `:${env.MIFI_LOG_NAMESPACES}` : ""}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment);
|
||||||
|
const runtime = resolveRuntime(options.runtime);
|
||||||
|
const sinks: readonly LogSink[] = options.sinks ?? [
|
||||||
|
...(runtime === "node" || options.environment !== "production" || !options.sentry
|
||||||
|
? [createConsoleSink()]
|
||||||
|
: []),
|
||||||
|
...(options.environment === "production" && options.sentry ? [options.sentry] : []),
|
||||||
|
];
|
||||||
|
const enabled = (level: Exclude<LogLevel, "silent">) =>
|
||||||
|
levelRank(level) >= levelRank(threshold) &&
|
||||||
|
(!runtimeOverride?.namespaces.length ||
|
||||||
|
namespaceMatches(namespace, runtimeOverride.namespaces));
|
||||||
|
const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => {
|
||||||
|
if (!enabled(level)) return;
|
||||||
|
const event: LogEvent = {
|
||||||
|
level,
|
||||||
|
namespace,
|
||||||
|
arguments: evaluate(arguments_),
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
sinks.forEach((sink) => sink.emit(event));
|
||||||
|
};
|
||||||
|
const consoleMethod = (
|
||||||
|
method: keyof Console | "profile" | "profileEnd",
|
||||||
|
level: Exclude<LogLevel, "silent">,
|
||||||
|
arguments_: readonly unknown[],
|
||||||
|
) => {
|
||||||
|
if (!enabled(level)) return;
|
||||||
|
const console_ = globalThis.console as Console & Record<string, unknown>;
|
||||||
|
const fn = console_[method];
|
||||||
|
if (typeof fn === "function")
|
||||||
|
(fn as (...items: unknown[]) => void).apply(
|
||||||
|
console_,
|
||||||
|
Array.from(prefix(namespace, evaluate(arguments_))),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const api: Logger = {
|
||||||
|
namespace,
|
||||||
|
child: (child) =>
|
||||||
|
createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }),
|
||||||
|
trace: (...items) => emit("trace", items),
|
||||||
|
debug: (...items) => emit("debug", items),
|
||||||
|
log: (...items) => emit("info", items),
|
||||||
|
info: (...items) => emit("info", items),
|
||||||
|
warn: (...items) => emit("warn", items),
|
||||||
|
error: (...items) => emit("error", items),
|
||||||
|
assert: (condition, ...items) => {
|
||||||
|
if (!condition) emit("error", ["Assertion failed", ...items]);
|
||||||
|
},
|
||||||
|
group: (...items) => consoleMethod("group", "info", items),
|
||||||
|
groupCollapsed: (...items) => consoleMethod("groupCollapsed", "info", items),
|
||||||
|
groupEnd: () => consoleMethod("groupEnd", "info", []),
|
||||||
|
dir: (item, options_) => consoleMethod("dir", "info", [item, options_]),
|
||||||
|
dirxml: (...items) => consoleMethod("dirxml", "info", items),
|
||||||
|
table: (data, properties) => consoleMethod("table", "info", [data, properties]),
|
||||||
|
clear: () => consoleMethod("clear", "info", []),
|
||||||
|
count: (label) => consoleMethod("count", "info", [label]),
|
||||||
|
countReset: (label) => consoleMethod("countReset", "info", [label]),
|
||||||
|
time: (label) => consoleMethod("time", "info", [label]),
|
||||||
|
timeLog: (label, ...items) => consoleMethod("timeLog", "info", [label, ...items]),
|
||||||
|
timeEnd: (label) => consoleMethod("timeEnd", "info", [label]),
|
||||||
|
timeStamp: (label) => consoleMethod("timeStamp", "info", [label]),
|
||||||
|
profile: (label) => consoleMethod("profile", "info", [label]),
|
||||||
|
profileEnd: (label) => consoleMethod("profileEnd", "info", [label]),
|
||||||
|
};
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] {
|
||||||
|
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) => {
|
||||||
|
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,37 @@
|
|||||||
|
import type { LogEvent, LogSink } from "./types.js";
|
||||||
|
|
||||||
|
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") 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("error");
|
||||||
|
if (event.namespace) scope.setTag("logger.namespace", event.namespace);
|
||||||
|
scope.setExtras(extras);
|
||||||
|
if (error) sentry.captureException(error);
|
||||||
|
else sentry.captureMessage(message, "error");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/** A severity threshold, ordered from most to least verbose. */
|
||||||
|
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 {
|
||||||
|
level: Exclude<LogLevel, "silent">;
|
||||||
|
namespace?: string;
|
||||||
|
arguments: readonly unknown[];
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destination for enabled log events. */
|
||||||
|
export interface LogSink {
|
||||||
|
emit(event: LogEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoggerOptions {
|
||||||
|
environment: LoggerEnvironment;
|
||||||
|
namespace?: string | readonly string[];
|
||||||
|
/** Runtime used for the default destination policy. It is inferred when omitted. */
|
||||||
|
runtime?: "browser" | "node";
|
||||||
|
/** Hard override. It takes precedence over runtime environment defaults. */
|
||||||
|
level?: LogLevel;
|
||||||
|
/** Browser-only session override source; defaults to global sessionStorage when available. */
|
||||||
|
sessionStorage?: Pick<Storage, "getItem">;
|
||||||
|
/** Node/container environment source; defaults to process.env when available. */
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
/** Production error destination. Browser errors go only here; Node errors also go to stderr. */
|
||||||
|
sentry?: LogSink | false;
|
||||||
|
/** Complete destination override. When provided, `sentry` and default console behavior are not used. */
|
||||||
|
sinks?: readonly LogSink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
readonly namespace?: string;
|
||||||
|
child(namespace: string): Logger;
|
||||||
|
trace(...arguments_: readonly unknown[]): void;
|
||||||
|
debug(...arguments_: readonly unknown[]): void;
|
||||||
|
log(...arguments_: readonly unknown[]): void;
|
||||||
|
info(...arguments_: readonly unknown[]): void;
|
||||||
|
warn(...arguments_: readonly unknown[]): void;
|
||||||
|
error(...arguments_: readonly unknown[]): void;
|
||||||
|
assert(condition: unknown, ...arguments_: readonly unknown[]): void;
|
||||||
|
group(...arguments_: readonly unknown[]): void;
|
||||||
|
groupCollapsed(...arguments_: readonly unknown[]): void;
|
||||||
|
groupEnd(): void;
|
||||||
|
dir(item?: unknown, options?: unknown): void;
|
||||||
|
dirxml(...arguments_: readonly unknown[]): void;
|
||||||
|
table(tabularData?: unknown, properties?: readonly string[]): void;
|
||||||
|
clear(): void;
|
||||||
|
count(label?: string): void;
|
||||||
|
countReset(label?: string): void;
|
||||||
|
time(label?: string): void;
|
||||||
|
timeLog(label?: string, ...arguments_: readonly unknown[]): void;
|
||||||
|
timeEnd(label?: string): void;
|
||||||
|
timeStamp(label?: string): void;
|
||||||
|
profile(label?: string): void;
|
||||||
|
profileEnd(label?: string): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createLogger, parseLoggingOverride } from "../src/index.js";
|
||||||
|
import type { LogEvent, LogSink } from "../src/index.js";
|
||||||
|
|
||||||
|
function sink(): { sink: LogSink; events: LogEvent[] } {
|
||||||
|
const events: LogEvent[] = [];
|
||||||
|
return { events, sink: { emit: (event) => events.push(event) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseLoggingOverride", () => {
|
||||||
|
it("accepts a global level and a namespace-filtered level", () => {
|
||||||
|
expect(parseLoggingOverride("debug")).toEqual({ level: "debug", namespaces: [] });
|
||||||
|
expect(parseLoggingOverride("debug:WIDGET, API")).toEqual({
|
||||||
|
level: "debug",
|
||||||
|
namespaces: ["WIDGET", "API"],
|
||||||
|
});
|
||||||
|
expect(parseLoggingOverride("verbose")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLogger", () => {
|
||||||
|
it("uses environment defaults and never evaluates suppressed lazy data", () => {
|
||||||
|
const destination = sink();
|
||||||
|
const expensive = vi.fn(() => ({ huge: "snapshot" }));
|
||||||
|
const logger = createLogger({ environment: "production", sinks: [destination.sink] });
|
||||||
|
logger.debug("ignored", expensive);
|
||||||
|
logger.error("kept", expensive);
|
||||||
|
expect(expensive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(destination.events).toHaveLength(1);
|
||||||
|
expect(destination.events[0]?.arguments).toEqual(["kept", { huge: "snapshot" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies session namespace filtering to namespace descendants", () => {
|
||||||
|
const destination = sink();
|
||||||
|
const storage = { getItem: () => "debug:WIDGET" };
|
||||||
|
createLogger({
|
||||||
|
environment: "production",
|
||||||
|
namespace: "WIDGET:Button",
|
||||||
|
sessionStorage: storage,
|
||||||
|
sinks: [destination.sink],
|
||||||
|
}).debug("shown");
|
||||||
|
createLogger({
|
||||||
|
environment: "production",
|
||||||
|
namespace: "API",
|
||||||
|
sessionStorage: storage,
|
||||||
|
sinks: [destination.sink],
|
||||||
|
}).debug("hidden");
|
||||||
|
expect(destination.events.map((event) => event.arguments[0])).toEqual(["shown"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds immutable child namespaces and reports failed assertions as errors", () => {
|
||||||
|
const destination = sink();
|
||||||
|
const root = createLogger({
|
||||||
|
environment: "development",
|
||||||
|
namespace: "WIDGET",
|
||||||
|
sinks: [destination.sink],
|
||||||
|
});
|
||||||
|
root.child("Button").assert(false, "missing label");
|
||||||
|
expect(root.namespace).toBe("WIDGET");
|
||||||
|
expect(destination.events[0]).toMatchObject({
|
||||||
|
level: "error",
|
||||||
|
namespace: "WIDGET:Button",
|
||||||
|
arguments: ["Assertion failed", "missing label"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends production browser errors to Sentry without writing to the console", () => {
|
||||||
|
const destination = sink();
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
const logger = createLogger({
|
||||||
|
environment: "production",
|
||||||
|
runtime: "browser",
|
||||||
|
sentry: destination.sink,
|
||||||
|
});
|
||||||
|
logger.error("captured");
|
||||||
|
expect(destination.events).toHaveLength(1);
|
||||||
|
expect(consoleError).not.toHaveBeenCalled();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production Node errors on stderr as well as sending them to Sentry", () => {
|
||||||
|
const destination = sink();
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
const logger = createLogger({
|
||||||
|
environment: "production",
|
||||||
|
runtime: "node",
|
||||||
|
sentry: destination.sink,
|
||||||
|
});
|
||||||
|
logger.error("captured");
|
||||||
|
expect(destination.events).toHaveLength(1);
|
||||||
|
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createLogger } from "../src/index.js";
|
||||||
|
import { createSentrySink } from "../src/sentry.js";
|
||||||
|
|
||||||
|
describe("createSentrySink", () => {
|
||||||
|
it("captures errors with namespace and structured context, but ignores warnings", () => {
|
||||||
|
const scope = { setLevel: vi.fn(), setTag: vi.fn(), setExtras: vi.fn() };
|
||||||
|
const sentry = {
|
||||||
|
withScope: (callback: (value: typeof scope) => void) => callback(scope),
|
||||||
|
captureException: vi.fn(),
|
||||||
|
captureMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
const logger = createLogger({
|
||||||
|
environment: "production",
|
||||||
|
namespace: "API",
|
||||||
|
sinks: [createSentrySink(sentry)],
|
||||||
|
});
|
||||||
|
const error = new Error("offline");
|
||||||
|
logger.warn("ignored");
|
||||||
|
logger.error("Request failed", error, { requestId: "req_1" });
|
||||||
|
expect(sentry.captureException).toHaveBeenCalledWith(error);
|
||||||
|
expect(scope.setTag).toHaveBeenCalledWith("logger.namespace", "API");
|
||||||
|
expect(scope.setExtras).toHaveBeenCalledWith({ context_0: { requestId: "req_1" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2024",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "tsup.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: { index: "src/index.ts", sentry: "src/sentry.ts" },
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
target: "es2022",
|
||||||
|
sourcemap: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { environment: "node", coverage: { provider: "v8", reporter: ["text", "html"] } },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user