10 Commits
Author SHA1 Message Date
semantic-release-bot 9f18e0855e chore(release): 0.10.0 [skip ci]
# [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](44f471653e))
2026-08-05 16:06:59 +00:00
mifi 44f471653e feat(logger): add explicit Sentry event channel
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-08-05 13:05:06 -03:00
semantic-release-bot 0ccb7f911d chore(release): 0.9.3 [skip ci]
## [0.9.3](https://git.mifi.dev/mifi/logger/compare/v0.9.2...v0.9.3) (2026-08-05)

### Bug Fixes

* **release:** build package before publishing ([e0b4501](e0b450157d))
* **release:** build package before publishing ([cebe107](cebe107034))
2026-08-05 12:34:44 +00:00
mifi e0b450157d fix(release): build package before publishing
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-08-05 09:32:51 -03:00
mifi cebe107034 fix(release): build package before publishing
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish unknown status
2026-08-05 09:27:19 -03:00
semantic-release-bot 47e3930b4b chore(release): 0.9.2 [skip ci]
## [0.9.2](https://git.mifi.dev/mifi/logger/compare/v0.9.1...v0.9.2) (2026-08-05)

### Bug Fixes

* **ci:** authenticate semantic release pushes ([1c63e58](1c63e58809))
* **ci:** install CA certificates for release workflows ([d211d9f](d211d9f8bc))
* **ci:** install git for release workflows ([afd2ca1](afd2ca1978))
2026-08-05 04:40:55 +00:00
mifi 1c63e58809 fix(ci): authenticate semantic release pushes
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-08-05 01:38:41 -03:00
mifi d211d9f8bc fix(ci): install CA certificates for release workflows
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-08-05 01:35:07 -03:00
mifi afd2ca1978 fix(ci): install git for release workflows
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-08-05 01:31:39 -03:00
mifi 934367d695 chore(ci): configure release automation
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-08-05 01:29:07 -03:00
11 changed files with 164 additions and 11 deletions
+57
View File
@@ -0,0 +1,57 @@
# One-time bootstrap for the v0.9.1 tag, whose original tag workflow failed before publishing.
when:
- event: manual
steps:
publish-v0-9-1:
image: node:24-bookworm-slim
environment:
NPM_TOKEN:
from_secret: gitea_package_token
commands:
- corepack enable
- corepack prepare pnpm@11.0.0 --activate
- apt-get update && apt-get install --yes --no-install-recommends ca-certificates git
- pnpm install --frozen-lockfile
- test "$(node -p \"require('./package.json').version\")" = "0.9.1"
- git rev-parse --verify refs/tags/v0.9.1
- pnpm check
- pnpm test
- pnpm build
- npm config set @mifi:registry https://git.mifi.dev/api/packages/mifi/npm/
- npm config set -- //git.mifi.dev/api/packages/mifi/npm/:_authToken "$NPM_TOKEN"
- pnpm publish --no-git-checks
notify-bootstrap-failure:
image: curlimages/curl:8.14.1
depends_on: [publish-v0-9-1]
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Package bootstrap failure 💩"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl --fail --show-error --silent --request POST --header 'Content-Type: application/json' --header "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" --data "$BODY" "$MATTERMOST_POST_API_URL"
when:
- status: [failure]
notify-bootstrap-success:
image: curlimages/curl:8.14.1
depends_on: [publish-v0-9-1]
environment:
MATTERMOST_BOT_ACCESS_TOKEN:
from_secret: mattermost_bot_access_token
MATTERMOST_CHANNEL_ID:
from_secret: mattermost_pushes_channel_id
MATTERMOST_POST_API_URL:
from_secret: mattermost_post_api_url
commands:
- |
BODY=$(printf '{"channel_id":"%s","message":"[%s - Build #%s] Package bootstrap success 🎉"}' "$MATTERMOST_CHANNEL_ID" "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl --fail --show-error --silent --request POST --header 'Content-Type: application/json' --header "Authorization: Bearer $MATTERMOST_BOT_ACCESS_TOKEN" --data "$BODY" "$MATTERMOST_POST_API_URL"
when:
- status: [success]
+9 -2
View File
@@ -7,13 +7,18 @@ depends_on:
- ci - ci
clone: clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 0 depth: 0
partial: false
tags: true
steps: steps:
release: release:
image: node:24-bookworm-slim image: node:24-bookworm-slim
environment: environment:
GITEA_PACKAGE_TOKEN: NPM_TOKEN:
from_secret: gitea_package_token from_secret: gitea_package_token
GITEA_REGISTRY_USERNAME: GITEA_REGISTRY_USERNAME:
from_secret: gitea_registry_username from_secret: gitea_registry_username
@@ -22,12 +27,14 @@ steps:
commands: commands:
- corepack enable - corepack enable
- corepack prepare pnpm@11.0.0 --activate - corepack prepare pnpm@11.0.0 --activate
- apt-get update && apt-get install --yes --no-install-recommends ca-certificates git
- pnpm install --frozen-lockfile - pnpm install --frozen-lockfile
- git config user.name "Woodpecker CI" - git config user.name "Woodpecker CI"
- git config user.email "woodpecker-ci@mifi.dev" - git config user.email "woodpecker-ci@mifi.dev"
- git remote set-url origin "https://${GITEA_REGISTRY_USERNAME}:${GITEA_RELEASE_TOKEN}@git.mifi.dev/mifi/logger.git" - git remote set-url origin "https://${GITEA_REGISTRY_USERNAME}:${GITEA_RELEASE_TOKEN}@git.mifi.dev/mifi/logger.git"
- npm config set @mifi:registry https://git.mifi.dev/api/packages/mifi/npm/ - 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" - npm config set -- //git.mifi.dev/api/packages/mifi/npm/:_authToken "$NPM_TOKEN"
- pnpm build
- pnpm release - pnpm release
notify-release-failure: notify-release-failure:
+21
View File
@@ -0,0 +1,21 @@
# [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)
### Bug Fixes
- **release:** build package before publishing ([e0b4501](https://git.mifi.dev/mifi/logger/commit/e0b450157df7a8aa1ff8bf5299140b0e7b52a7f7))
- **release:** build package before publishing ([cebe107](https://git.mifi.dev/mifi/logger/commit/cebe107034a15e9fd1517d872037e97337426b29))
## [0.9.2](https://git.mifi.dev/mifi/logger/compare/v0.9.1...v0.9.2) (2026-08-05)
### Bug Fixes
- **ci:** authenticate semantic release pushes ([1c63e58](https://git.mifi.dev/mifi/logger/commit/1c63e58809049e8a3a4da00601b09e60a5482ff1))
- **ci:** install CA certificates for release workflows ([d211d9f](https://git.mifi.dev/mifi/logger/commit/d211d9f8bc205ae6b952e35603773e44bca5ee37))
- **ci:** install git for release workflows ([afd2ca1](https://git.mifi.dev/mifi/logger/commit/afd2ca1978d7373daa4b6ffa9067122586ba5f86))
+5 -1
View File
@@ -22,7 +22,7 @@ Containers can use `MIFI_LOG_LEVEL` and `MIFI_LOG_NAMESPACES`. Explicit `level`
## 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. 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.
```ts ```ts
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
@@ -30,6 +30,8 @@ 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", sentry: createSentrySink(Sentry) });
logger.sentry.info("Cache warmed", { entries: 42 });
``` ```
## Releases ## Releases
@@ -39,3 +41,5 @@ Woodpecker verifies pull requests and `main`, reports CI to Mattermost, then aut
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 `!` 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.
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.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mifi/logger", "name": "@mifi/logger",
"version": "0.9.1", "version": "0.10.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",
@@ -32,6 +32,7 @@
}, },
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"prepack": "npm run build",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
+8
View File
@@ -1,5 +1,13 @@
const releaseUsername = process.env.GITEA_REGISTRY_USERNAME;
const releaseToken = process.env.GITEA_RELEASE_TOKEN;
const repositoryUrl =
releaseUsername && releaseToken
? `https://${encodeURIComponent(releaseUsername)}:${encodeURIComponent(releaseToken)}@git.mifi.dev/mifi/logger.git`
: "https://git.mifi.dev/mifi/logger.git";
module.exports = { module.exports = {
branches: ["main"], branches: ["main"],
repositoryUrl,
tagFormat: "v${version}", tagFormat: "v${version}",
plugins: [ plugins: [
"@semantic-release/commit-analyzer", "@semantic-release/commit-analyzer",
+18 -2
View File
@@ -97,13 +97,20 @@ export function createLogger(options: LoggerOptions): Logger {
levelRank(level) >= levelRank(threshold) && levelRank(level) >= levelRank(threshold) &&
(!runtimeOverride?.namespaces.length || (!runtimeOverride?.namespaces.length ||
namespaceMatches(namespace, runtimeOverride.namespaces)); namespaceMatches(namespace, runtimeOverride.namespaces));
const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => { const emit = (
if (!enabled(level)) return; level: Exclude<LogLevel, "silent">,
arguments_: readonly unknown[],
sendToSentry = false,
) => {
const sendToConsole = enabled(level);
if (!sendToConsole && !sendToSentry) return;
const event: LogEvent = { const event: LogEvent = {
level, level,
namespace, namespace,
arguments: evaluate(arguments_), arguments: evaluate(arguments_),
timestamp: new Date(), timestamp: new Date(),
sendToSentry,
sendToConsole,
}; };
sinks.forEach((sink) => sink.emit(event)); sinks.forEach((sink) => sink.emit(event));
}; };
@@ -123,6 +130,14 @@ 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),
@@ -187,6 +202,7 @@ function supportsAnsi(): boolean {
export function createConsoleSink(): LogSink { export function createConsoleSink(): LogSink {
return { return {
emit: (event) => { emit: (event) => {
if (!event.sendToConsole) return;
const method = event.level === "trace" ? "debug" : event.level; const method = event.level === "trace" ? "debug" : event.level;
const console_ = globalThis.console as Console & Record<string, unknown>; const console_ = globalThis.console as Console & Record<string, unknown>;
const fn = console_[method]; const fn = console_[method];
+3 -3
View File
@@ -15,7 +15,7 @@ export interface SentryLike {
export function createSentrySink(sentry: SentryLike): LogSink { export function createSentrySink(sentry: SentryLike): LogSink {
return { return {
emit(event: LogEvent) { emit(event: LogEvent) {
if (event.level !== "error") return; if (event.level !== "error" && !event.sendToSentry) return;
const error = event.arguments.find((item): item is Error => item instanceof Error); const error = event.arguments.find((item): item is Error => item instanceof Error);
const extras = Object.fromEntries( const extras = Object.fromEntries(
event.arguments event.arguments
@@ -26,11 +26,11 @@ export function createSentrySink(sentry: SentryLike): LogSink {
event.arguments.filter((item) => typeof item === "string").join(" ") || event.arguments.filter((item) => typeof item === "string").join(" ") ||
"Logger error"; "Logger error";
sentry.withScope((scope) => { sentry.withScope((scope) => {
scope.setLevel("error"); scope.setLevel(event.level);
if (event.namespace) scope.setTag("logger.namespace", event.namespace); if (event.namespace) scope.setTag("logger.namespace", event.namespace);
scope.setExtras(extras); scope.setExtras(extras);
if (error) sentry.captureException(error); if (error) sentry.captureException(error);
else sentry.captureMessage(message, "error"); else sentry.captureMessage(message, event.level);
}); });
}, },
}; };
+16
View File
@@ -9,6 +9,10 @@ export interface LogEvent {
namespace?: string; namespace?: string;
arguments: readonly unknown[]; arguments: readonly unknown[];
timestamp: Date; timestamp: Date;
/** Whether this event was explicitly selected for Sentry capture. */
sendToSentry: boolean;
/** Whether the default console sink should render this event. */
sendToConsole: boolean;
} }
/** A destination for enabled log events. */ /** A destination for enabled log events. */
@@ -33,8 +37,20 @@ export interface LoggerOptions {
sinks?: readonly LogSink[]; sinks?: readonly LogSink[];
} }
/** Explicit Sentry reporting methods. These bypass the logger's normal level threshold. */
export interface SentryLogger {
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;
}
export interface Logger { export interface Logger {
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;
child(namespace: string): Logger; child(namespace: string): Logger;
trace(...arguments_: readonly unknown[]): void; trace(...arguments_: readonly unknown[]): void;
debug(...arguments_: readonly unknown[]): void; debug(...arguments_: readonly unknown[]): void;
+20
View File
@@ -78,6 +78,26 @@ describe("createLogger", () => {
consoleError.mockRestore(); consoleError.mockRestore();
}); });
it("sends explicitly selected production events to Sentry without writing them to the console", () => {
const destination = sink();
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
const logger = createLogger({
environment: "production",
runtime: "browser",
sentry: destination.sink,
});
logger.sentry.info("Cache warmed");
expect(destination.events).toHaveLength(1);
expect(destination.events[0]).toMatchObject({
level: "info",
arguments: ["Cache warmed"],
sendToSentry: true,
sendToConsole: false,
});
expect(consoleInfo).not.toHaveBeenCalled();
consoleInfo.mockRestore();
});
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);
+4 -1
View File
@@ -3,7 +3,7 @@ import { createLogger } from "../src/index.js";
import { createSentrySink } from "../src/sentry.js"; import { createSentrySink } from "../src/sentry.js";
describe("createSentrySink", () => { describe("createSentrySink", () => {
it("captures errors with namespace and structured context, but ignores warnings", () => { 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 = { const sentry = {
withScope: (callback: (value: typeof scope) => void) => callback(scope), withScope: (callback: (value: typeof scope) => void) => callback(scope),
@@ -17,8 +17,11 @@ describe("createSentrySink", () => {
}); });
const error = new Error("offline"); const error = new Error("offline");
logger.warn("ignored"); logger.warn("ignored");
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(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({ context_0: { requestId: "req_1" } });
}); });