@muhammetgoktug/mg-logger
v0.6.0
Published
Self-hosted log, error & performance monitoring SDK. Works in Node.js, Next.js, React, React Native and browsers.
Maintainers
Readme
mg_logger SDK
Self-hosted log, error & performance monitoring SDK. Works in Node.js, Next.js, React, React Native and browsers.
- Zero runtime deps (no zod, no axios — uses native
fetch). - Tree-shakeable subpath exports per platform.
- Dual ESM/CJS build with TypeScript declarations.
- Tiny: each platform entry ships only what it needs.
Publish to npm (maintainer cheatsheet)
Quick reference for shipping a new version. Run from the sdk/ directory.
cd mg_logger_sdknpm whoaminpm install# Bump the version. NOTE: also bump SDK_INFO.version in the 5 entry files below
# to match — `npm version` only touches package.json. Skip this step if you've
# already set the version manually in all 6 places.
npm version patch # 0.6.0 → 0.6.1 (bug fixes)
# npm version minor # 0.6.0 → 0.7.0 (new feature, backwards compatible)
# npm version major # 0.6.0 → 1.0.0 (breaking change)npm run build # tsup → dist/ (also runs automatically via prepublishOnly)npm publish --access public # NOT `npm run publish` — there is no such scriptgit push --follow-tagsThe 5 entry files whose SDK_INFO.version must match package.json:
core/client.ts, node/index.ts, nextjs/server.ts, nextjs/client.ts,
react-native.ts. They are currently all at 0.6.0.
Install
npm install @muhammetgoktug/mg-logger
# or
pnpm add @muhammetgoktug/mg-logger
# or
yarn add @muhammetgoktug/mg-loggerDSN format
<protocol>://<publicKey>@<host>[:port]/<projectId>Example: https://[email protected]/4
The SDK posts envelopes to ${origin}/api/${projectId}/envelope.
Same-origin proxy (no CORS)
If your ingest server doesn't expose CORS headers (deliberate hardening mirroring Sentry's design), point the browser at a same-origin path that your Next.js / Express / Nginx server forwards to the real ingest. No preflight ever fires.
next.config.ts:
async rewrites() {
return [
{ source: "/_ek/:path*", destination: "https://ingest.example.com/api/:path*" },
];
}instrumentation-client.ts:
import { initBrowser } from "@muhammetgoktug/mg-logger/nextjs/client";
initBrowser({
dsn: process.env.NEXT_PUBLIC_MG_LOGGER_DSN!,
sameOriginProxy: "/_ek", // SDK posts to /_ek/<projectId>/envelope
});The SDK parses the project id from the DSN and builds
/_ek/<projectId>/envelope automatically. Server-side init() still uses
the absolute DSN host (no CORS concern there).
For full control, pass ingestUrl directly — wins over sameOriginProxy.
Scope attributes
setAttribute(key, value) rides on every log.*, captureHttp, and span
event emitted afterwards — handy for per-route correlation ids:
import { setAttribute, log, captureHttp } from "@muhammetgoktug/mg-logger";
// e.g. in a RouteViewLogger:
setAttribute("view_id", crypto.randomUUID());
log.info("page.view", { path: "/dashboard" });
// later, every API call's captureHttp() carries the same view_id automatically
captureHttp({ method: "GET", url: "/api/me", status: 200, duration_ms: 42 });Available APIs: setAttribute, removeAttribute, clearAttributes.
(Scope tags via setTag still flow only into captureException / captureMessage.)
Node.js
import { init, captureException, log } from "@muhammetgoktug/mg-logger/node";
init({
dsn: process.env.MG_LOGGER_DSN!,
environment: process.env.NODE_ENV,
release: process.env.GIT_SHA,
// installs uncaughtException + unhandledRejection handlers by default
});
try {
doWork();
} catch (err) {
captureException(err);
}
log.info("worker started", { workerId: 1 });Optional Express-shaped middleware (no express runtime dependency — typed structurally):
import express from "express";
import { tracingHandler, errorHandler } from "@muhammetgoktug/mg-logger/node";
const app = express();
app.use(tracingHandler()); // before routes — emits APM spans
// ... your routes ...
app.use(errorHandler()); // after routes — captures thrown errorsNext.js (App Router)
Server
Create instrumentation.ts in the project root:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { init } = await import("@muhammetgoktug/mg-logger/nextjs");
init({
dsn: process.env.MG_LOGGER_DSN!,
environment: process.env.NODE_ENV,
release: process.env.GIT_SHA,
});
}
}
// Next 15+: forward route errors to mg_logger
export { captureRequestError as onRequestError } from "@muhammetgoktug/mg-logger/nextjs";Client
// app/providers.tsx
"use client";
import { useEffect } from "react";
import { initBrowser } from "@muhammetgoktug/mg-logger/nextjs/client";
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
initBrowser({
dsn: process.env.NEXT_PUBLIC_MG_LOGGER_DSN!,
environment: process.env.NODE_ENV,
// Both default to on — shown here for visibility.
autoInstrument: true, // fetch + xhr + console + navigation breadcrumbs
captureContext: true, // browser/os/viewport context on every event
});
}, []);
return <>{children}</>;
}Automatic breadcrumbs & context (browser)
initBrowser installs automatic breadcrumbs and captures device context out of
the box. Everything is fail-soft (a broken hook never breaks your page) and
network breadcrumbs skip your own ingest endpoint to avoid feedback loops.
initBrowser({
dsn,
autoInstrument: {
fetch: true, // default on
xhr: true, // default on
console: true, // console.error / console.warn → breadcrumbs (default on)
navigation: true, // SPA route changes (default on)
domClicks: false, // click selectors — OFF by default (can leak PII)
},
captureContext: true, // browser name/version, OS, viewport, locale
});Pass autoInstrument: false to disable all of them, or captureContext: false
to skip context. Use setContext("key", {...}) to attach your own context group,
and beforeSend to redact sensitive breadcrumb/URL data.
The auto-instrumentation code lives only in the nextjs/client (and
react-native) bundles — the universal index entry stays free of it, so the
core import is unchanged and tree-shakeable.
React
import { ErrorBoundary } from "@muhammetgoktug/mg-logger/react";
export default function App() {
return (
<ErrorBoundary fallback={<p>Something broke.</p>}>
<Routes />
</ErrorBoundary>
);
}React Native
import { initReactNative, captureException } from "@muhammetgoktug/mg-logger/react-native";
initReactNative({
dsn: "https://your-key@your-host/project-id",
environment: __DEV__ ? "development" : "production",
appVersion: "1.2.3", // surfaced under the `app` context (RN can't read it alone)
build: "456",
});
// hooks into RN's ErrorUtils for uncaught JS fatals; manual capture also works:
try { ... } catch (e) { captureException(e); }initReactNative automatically: hooks ErrorUtils (fatal/non-fatal JS errors),
tracks unhandled promise rejections, installs network + console breadcrumbs,
captures device/OS context, and persists fatal/error events offline so a JS
crash that happens before the next flush is replayed on the next launch.
initReactNative({
dsn,
autoInstrument: { fetch: true, xhr: true, console: true }, // all default on
captureContext: true, // device/os context (default on)
enableStallDetector: true, // JS-thread stall ("ANR-ish") heartbeat (default off)
stallThresholdMs: 5000,
// persistence: "auto" (default on RN) — AsyncStorage if installed, else no-op
});The stall detector is JS-only: it flags long synchronous JS work, but it is not a true native ANR (see native module below). Offline persistence uses the optional peer
@react-native-async-storage/async-storage— install it to enable durable replay; without it the SDK degrades silently.
Navigation breadcrumbs (React Navigation)
No dependency on react-navigation — wire the hook yourself:
import { createNavigationBreadcrumbHook } from "@muhammetgoktug/mg-logger/react-native";
const onStateChange = createNavigationBreadcrumbHook(client);
<NavigationContainer onStateChange={onStateChange}>{...}</NavigationContainer>Native crashes & ANR (opt-in)
The default RN entry catches JS-level errors only. To also capture native
crashes (iOS signals/NSException, Android JVM crashes) and ANRs, use the
react-native/native subpath. This ships native code, so it requires
autolinking + pod install (and is autolinked for Expo via the bundled config
plugin):
import { initReactNativeWithNative } from "@muhammetgoktug/mg-logger/react-native/native";
initReactNativeWithNative({ dsn, appVersion: "1.2.3" });Native handlers write a crash report to disk at crash time (when JS/network
can't run) and the report is uploaded on the next launch. Handlers chain to
any previously-installed crash reporter (e.g. Crashlytics). Native stacks are
stored raw for now (native symbolication via dSYM/ProGuard mapping is a later
phase). Expo apps add the plugin in app.json:
{ "expo": { "plugins": ["@muhammetgoktug/mg-logger"] } }HTTP body capture
captureHttp({ method, url, status, duration_ms, request_body, response_body })
records a request/response pair as a structured log. Bodies are truncated to
maxHttpBodyBytes (default 256 KB) — when truncation happens the SDK
sets sibling attributes so the viewer can still pretty-print the prefix:
attributes.response_body // prefix, no in-band marker
attributes.response_body_truncated // "true"
attributes.response_body_original_size// "358400"Tune via init({ maxHttpBodyBytes, maxEnvelopeBytes }):
| Option | Default | Notes |
|---|---|---|
| maxHttpBodyBytes | 256 * 1024 | Per-body cap. 0 disables (unsafe). |
| maxEnvelopeBytes | 800 * 1024 | Transport splits a batched envelope into multiple POSTs when serialized size exceeds this. Keeps a margin under the server's 4 MB body limit. |
A single oversize item is still sent on its own; the server's hard cap and
LOG_BODY_HARD_CAP_BYTES (default 1 MB) become the final gate.
Source maps (de-minify stack traces)
Web bundles (and Hermes release builds) ship minified, so stack traces arrive
with names like t in bundle.js. Upload your source maps per release and the
ingest server resolves frames to your original code before grouping, so
issues group on real names and the panel shows source context lines.
Run the bundled CLI in CI after each build:
npx mg-logger-sourcemaps \
--url https://ingest.example.com \
--key <public-key> \
--project 42 \
--release v1.2.3 \
./distIt uploads every .map under the given path, keyed by (project, release).
Re-uploading the same release overwrites it. Maps are stored server-side
(GridFS); the panel's Source maps page lists and deletes them, and old
releases are swept automatically (SOURCEMAP_KEEP_RELEASES, default 10).
Set the event's
release(viainit({ release })) to the same value you pass to--release, or frames can't be matched. Hermes: compose and upload the.hbc.mapfor the release. The CLI is dependency-free; map parsing happens only on the server, so the SDK stays zero-dep.
Public API (summary)
All entry points re-export the same universal helpers from ./core/singleton:
| Function | Description |
|---|---|
| init(options) | Create the global client (platform entries add their own handlers on top). |
| captureException(err, hint?) | Send an error event. |
| captureMessage(msg, level?) | Send a message event. |
| addBreadcrumb(crumb) | Attach a breadcrumb to subsequent events. |
| setUser(user) / setTag(k,v) / setExtra(k,v) | Mutate the global scope. |
| setContext(key, value) | Attach a device/os/app/custom context group. |
| flush() | Force-send the buffered envelope. |
| close() | Stop the timer and flush. |
| log.info / .warn / .error / .debug / .fatal | Structured logger. |
| initBrowser(options) | (nextjs/client) Browser variant of init; installs auto-breadcrumbs + context. |
| initReactNative(options) | (react-native) RN variant; auto-breadcrumbs, context, offline queue. |
| initReactNativeWithNative(options) | (react-native/native) RN + native crash/ANR capture (opt-in, needs native build). |
| createNavigationBreadcrumbHook(client) | (react-native) React Navigation onStateChange breadcrumb hook. |
| captureRequestError(...) | (nextjs) Map to Next 15's onRequestError. |
Pure helpers also exported from the root: parseDsn, parseStackString, parseStackTrace.
Most platform inits also accept persistence ("auto" | false | a custom
PersistentStore) — durable storage of unsent fatal/error events so a hard
crash replays them on the next launch. Default: on for browser/RN, off for node.
Raw HTTP fallback (no SDK)
Useful for edge runtimes, build environments, or anywhere the SDK is too heavy. Just POST JSON to the /store endpoint:
// store-only payload — server normalizes into the envelope schema.
async function captureErrorRaw(err: Error, dsn: string) {
const u = new URL(dsn);
const publicKey = u.username;
const projectId = u.pathname.replace(/^\//, "");
await fetch(`${u.origin}/api/${projectId}/store`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-ek-public-key": publicKey,
},
body: JSON.stringify({
type: "error",
level: "error",
message: err.message,
error_type: err.name,
stack: err.stack,
}),
keepalive: true, // browser: survive page unload
});
}Curl equivalent:
curl -X POST "https://sentry.example.com/api/4/store" \
-H "content-type: application/json" \
-H "x-ek-public-key: YOUR_PUBLIC_KEY" \
-d '{"type":"log","level":"info","message":"hello from curl"}'The /store endpoint accepts a single event; /envelope accepts a batch.
Next.js proxy pattern (avoid browser CORS)
Browsers sending events directly to a different origin (sentry.example.com) trigger a CORS preflight. To skip it, proxy through your own Next.js app at /_ek/* and point the browser's DSN at the same origin.
next.config.ts
import type { NextConfig } from "next";
// Derive the mg_logger ingest origin from the DSN so the rewrite has one source
// of truth. Browser sends events to /_ek/* (same-origin → no CORS preflight);
// Next forwards the request server-side to the real ingest host.
const ingestOrigin = process.env.NEXT_PUBLIC_MG_LOGGER_DSN
? new URL(process.env.NEXT_PUBLIC_MG_LOGGER_DSN).origin
: "";
const nextConfig: NextConfig = {
async rewrites() {
if (!ingestOrigin) return [];
return [
{ source: "/_ek/:path*", destination: `${ingestOrigin}/api/:path*` },
];
},
};
export default nextConfig;Same-origin DSN trick in the client wrapper
// src/lib/eksentry.ts — direct HTTP wrapper (no SDK), same-origin in the browser.
type Endpoint = { ingestUrl: string; publicKey: string } | null;
function parseDsn(dsn: string | undefined): Endpoint {
if (!dsn) return null;
try {
const u = new URL(dsn);
const publicKey = u.username;
const projectId = u.pathname.replace(/^\//, "");
if (!publicKey || !projectId) return null;
// Browser → same-origin proxy path; Server → absolute origin.
const ingestUrl =
typeof window === "undefined"
? `${u.origin}/api/${projectId}/store`
: `/_ek/${projectId}/store`;
return { ingestUrl, publicKey };
} catch {
return null;
}
}The same trick works for the SDK: if you build your DSN as https://[email protected]/PROJECT_ID in the browser, the SDK will hit https://your-app.com/api/PROJECT_ID/envelope — same origin → no preflight — and your Next rewrite forwards it to the real ingest.
License
MIT © Muhammet Goktug. See LICENSE.
