diff --git a/playground/README.md b/playground/README.md
index 0957782..64285e5 100644
--- a/playground/README.md
+++ b/playground/README.md
@@ -5,4 +5,5 @@ yarn playground:dev
```
Open the local URL printed by Vite. The demo uses the default bounded in-memory
-runtime, so it needs no adapter configuration or backend.
+runtime, so it needs no adapter configuration or backend. It queries GitHub's
+public API and shows the real response beside its metrics and structured logs.
diff --git a/playground/index.html b/playground/index.html
index 8ce1c37..0c48ef9 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -5,7 +5,7 @@
@haskou/metrics playground
@@ -13,26 +13,70 @@
@haskou/metrics playground
- Call a method. Watch the telemetry appear.
+ Make a real request. Inspect its telemetry.
- No setup. The default runtime keeps the latest 1,000 metrics and logs
- in memory.
+ Enter any public GitHub repository. One decorated method makes the
+ request while the default runtime records its calls, duration, logs,
+ and failures.
-
- Create user
- Simulate failure
- Clear
+
+
+
+
+ No request yet
+ Run the decorated method
+
+ The result will come from GitHub's public API.
+
+
+
+
Stars
+ —
+
+
+
Forks
+ —
+
+
+
Open issues
+ —
+
+
+
Updated
+ —
+
+
+
Calls 0
Failures 0
- Avg. duration 0 ms
+ Last duration 0 ms
- Buffered 0
+ Logs 0
@@ -41,21 +85,42 @@ Call a method. Watch the telemetry appear.
Metrics
latest first
- Run an action to collect metrics.
+
-
Logs
- stack traces on failure
+ Structured logs
+
+ Clear telemetry
+
- Run an action to collect logs.
+
+
+
+
The instrumented method
+ no configuration required
+
+ class GitHubRepositoryFinder {
+ @Metrics()
+ public async find(repository: string): Promise<Repository> {
+ const response = await fetch(githubUrl(repository));
+
+ if (!response.ok) {
+ throw new GitHubRepositoryRequestError(repository, response.status);
+ }
+
+ return response.json();
+ }
+}
+
+
- CPU and RAM sampling use Node.js process APIs and are demonstrated in
- the package tests.
+ This browser demo records real calls, failures, logs, and elapsed time.
+ CPU and RAM sampling require the Node.js adapter.
diff --git a/playground/src/errors/GitHubRepositoryRequestError.ts b/playground/src/errors/GitHubRepositoryRequestError.ts
new file mode 100644
index 0000000..06d2298
--- /dev/null
+++ b/playground/src/errors/GitHubRepositoryRequestError.ts
@@ -0,0 +1,6 @@
+export class GitHubRepositoryRequestError extends Error {
+ public constructor(repository: string, status: number) {
+ super(`GitHub returned HTTP ${status} for "${repository}".`);
+ this.name = 'GitHubRepositoryRequestError';
+ }
+}
diff --git a/playground/src/errors/InvalidGitHubRepositoryNameError.ts b/playground/src/errors/InvalidGitHubRepositoryNameError.ts
new file mode 100644
index 0000000..5e3f8ce
--- /dev/null
+++ b/playground/src/errors/InvalidGitHubRepositoryNameError.ts
@@ -0,0 +1,6 @@
+export class InvalidGitHubRepositoryNameError extends Error {
+ public constructor(repository: string) {
+ super(`"${repository}" must use the owner/repository format.`);
+ this.name = 'InvalidGitHubRepositoryNameError';
+ }
+}
diff --git a/playground/src/github/GitHubRepositoryFinder.ts b/playground/src/github/GitHubRepositoryFinder.ts
new file mode 100644
index 0000000..eb714b5
--- /dev/null
+++ b/playground/src/github/GitHubRepositoryFinder.ts
@@ -0,0 +1,46 @@
+import { Metrics } from '../../../src/index.js';
+import { GitHubRepositoryRequestError } from '../errors/GitHubRepositoryRequestError.js';
+import { InvalidGitHubRepositoryNameError } from '../errors/InvalidGitHubRepositoryNameError.js';
+import type { GitHubRepositorySummary } from './GitHubRepositorySummary.js';
+
+interface GitHubRepositoryResponse {
+ readonly description: string | null;
+ readonly forks_count: number;
+ readonly full_name: string;
+ readonly html_url: string;
+ readonly open_issues_count: number;
+ readonly stargazers_count: number;
+ readonly updated_at: string;
+}
+
+export class GitHubRepositoryFinder {
+ @Metrics()
+ public async find(repository: string): Promise {
+ const segments = repository.trim().split('/');
+
+ if (segments.length !== 2 || segments.some((segment) => !segment)) {
+ throw new InvalidGitHubRepositoryNameError(repository);
+ }
+
+ const path = segments.map(encodeURIComponent).join('/');
+ const response = await fetch(`https://api.github.com/repos/${path}`, {
+ headers: { Accept: 'application/vnd.github+json' },
+ });
+
+ if (!response.ok) {
+ throw new GitHubRepositoryRequestError(repository, response.status);
+ }
+
+ const payload: GitHubRepositoryResponse = await response.json();
+
+ return Object.freeze({
+ description: payload.description,
+ forks: payload.forks_count,
+ fullName: payload.full_name,
+ openIssues: payload.open_issues_count,
+ stars: payload.stargazers_count,
+ updatedAt: payload.updated_at,
+ url: payload.html_url,
+ });
+ }
+}
diff --git a/playground/src/github/GitHubRepositorySummary.ts b/playground/src/github/GitHubRepositorySummary.ts
new file mode 100644
index 0000000..b53f55b
--- /dev/null
+++ b/playground/src/github/GitHubRepositorySummary.ts
@@ -0,0 +1,9 @@
+export interface GitHubRepositorySummary {
+ readonly description: string | null;
+ readonly forks: number;
+ readonly fullName: string;
+ readonly openIssues: number;
+ readonly stars: number;
+ readonly updatedAt: string;
+ readonly url: string;
+}
diff --git a/playground/src/main.ts b/playground/src/main.ts
index 3d96c82..d2d674b 100644
--- a/playground/src/main.ts
+++ b/playground/src/main.ts
@@ -1,33 +1,54 @@
-import { Metrics, metrics } from '../../src/index.js';
-import { DemoUserCreationError } from './errors/DemoUserCreationError.js';
+import { metrics } from '../../src/index.js';
import { MissingPlaygroundElementError } from './errors/MissingPlaygroundElementError.js';
+import { GitHubRepositoryFinder } from './github/GitHubRepositoryFinder.js';
+import type { GitHubRepositorySummary } from './github/GitHubRepositorySummary.js';
import './style.css';
-class UserCreator {
- @Metrics()
- public async create(shouldFail = false): Promise {
- await new Promise((resolve) => setTimeout(resolve, 80));
+const finder = new GitHubRepositoryFinder();
- if (shouldFail) {
- throw new DemoUserCreationError();
- }
- }
-}
-
-const creator = new UserCreator();
-
-function element(identifier: string): HTMLElement {
+function element(
+ identifier: string,
+): ElementType {
const found = document.getElementById(identifier);
if (!found) {
throw new MissingPlaygroundElementError(identifier);
}
- return found;
+ return found as ElementType;
+}
+
+const form = element('repository-form');
+const repositoryInput = element('repository');
+const submitButton = element('inspect');
+const result = element('result');
+const resultStatus = element('result-status');
+const resultName = element('result-name');
+const resultDescription = element('result-description');
+const resultStars = element('result-stars');
+const resultForks = element('result-forks');
+const resultIssues = element('result-issues');
+const resultUpdated = element('result-updated');
+const metricOutput = element('metric-output');
+const logOutput = element('log-output');
+
+function formatTimestamp(timestamp: number): string {
+ return new Intl.DateTimeFormat('en', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }).format(timestamp);
}
-function render(): void {
+function emptyEvent(message: string): HTMLLIElement {
+ const item = document.createElement('li');
+ item.className = 'empty-event';
+ item.textContent = message;
+ return item;
+}
+
+function renderTelemetry(): void {
const snapshot = metrics.snapshot();
const calls = snapshot.metrics.filter(({ name }) => name.endsWith('.calls'));
const failures = snapshot.metrics.filter(({ name }) =>
@@ -36,40 +57,138 @@ function render(): void {
const durations = snapshot.metrics.filter(({ name }) =>
name.endsWith('.duration'),
);
- const averageDuration = durations.length
- ? durations.reduce((total, entry) => total + entry.value, 0) /
- durations.length
- : 0;
+ const lastDuration = durations.at(-1);
element('calls').textContent = calls.length.toString();
element('failures').textContent = failures.length.toString();
- element('duration').textContent = `${averageDuration.toFixed(1)} ms`;
- element('buffered').textContent = (
- snapshot.metrics.length + snapshot.logs.length
- ).toString();
- element('metric-output').textContent = snapshot.metrics.length
- ? JSON.stringify(snapshot.metrics.toReversed().slice(0, 12), null, 2)
- : 'Run an action to collect metrics.';
- element('log-output').textContent = snapshot.logs.length
- ? JSON.stringify(snapshot.logs.toReversed().slice(0, 8), null, 2)
- : 'Run an action to collect logs.';
+ element('duration').textContent = lastDuration
+ ? `${lastDuration.value.toFixed(1)} ms`
+ : '0 ms';
+ element('logs').textContent = snapshot.logs.length.toString();
+
+ const metricEvents = snapshot.metrics
+ .toReversed()
+ .slice(0, 12)
+ .map((entry) => {
+ const item = document.createElement('li');
+ const heading = document.createElement('div');
+ const name = document.createElement('strong');
+ const timestamp = document.createElement('time');
+ const value = document.createElement('span');
+
+ item.className = 'event';
+ heading.className = 'event-heading';
+ name.textContent = entry.name;
+ timestamp.textContent = formatTimestamp(entry.recordedAt);
+ timestamp.dateTime = new Date(entry.recordedAt).toISOString();
+ value.className = 'event-value';
+ value.textContent = `${entry.value.toFixed(entry.unit === 'milliseconds' ? 1 : 0)} ${entry.unit}`;
+ heading.append(name, timestamp);
+ item.append(heading, value);
+ return item;
+ });
+
+ metricOutput.replaceChildren(
+ ...(metricEvents.length
+ ? metricEvents
+ : [emptyEvent('Inspect a repository to collect metrics.')]),
+ );
+
+ const logEvents = snapshot.logs
+ .toReversed()
+ .slice(0, 8)
+ .map((entry) => {
+ const item = document.createElement('li');
+ const heading = document.createElement('div');
+ const level = document.createElement('span');
+ const timestamp = document.createElement('time');
+ const message = document.createElement('strong');
+
+ item.className = `event log-${entry.level}`;
+ heading.className = 'event-heading';
+ level.className = 'log-level';
+ level.textContent = entry.level;
+ timestamp.textContent = formatTimestamp(entry.recordedAt);
+ timestamp.dateTime = new Date(entry.recordedAt).toISOString();
+ message.className = 'log-message';
+ message.textContent = entry.message;
+ heading.append(level, timestamp);
+ item.append(heading, message);
+
+ if (entry.stackTrace) {
+ const details = document.createElement('details');
+ const summary = document.createElement('summary');
+ const stackTrace = document.createElement('pre');
+
+ summary.textContent = 'Stack trace';
+ stackTrace.textContent = entry.stackTrace;
+ details.append(summary, stackTrace);
+ item.append(details);
+ }
+
+ return item;
+ });
+
+ logOutput.replaceChildren(
+ ...(logEvents.length
+ ? logEvents
+ : [emptyEvent('Method calls and failures will appear here.')]),
+ );
+}
+
+function renderRepository(repository: GitHubRepositorySummary): void {
+ result.className = 'result result-success';
+ resultStatus.textContent = 'Live response from GitHub';
+ resultName.textContent = repository.fullName;
+ resultName.href = repository.url;
+ resultDescription.textContent =
+ repository.description ?? 'This repository has no description.';
+ resultStars.textContent = repository.stars.toLocaleString('en');
+ resultForks.textContent = repository.forks.toLocaleString('en');
+ resultIssues.textContent = repository.openIssues.toLocaleString('en');
+ resultUpdated.textContent = new Intl.DateTimeFormat('en', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(repository.updatedAt));
+}
+
+function renderFailure(error: unknown): void {
+ result.className = 'result result-failure';
+ resultStatus.textContent = 'Request failed';
+ resultName.removeAttribute('href');
+ resultName.textContent = 'GitHub did not return a repository';
+ resultDescription.textContent =
+ error instanceof Error ? error.message : String(error);
+ resultStars.textContent = '—';
+ resultForks.textContent = '—';
+ resultIssues.textContent = '—';
+ resultUpdated.textContent = '—';
}
-async function run(shouldFail: boolean): Promise {
+async function inspectRepository(): Promise {
+ submitButton.disabled = true;
+ submitButton.textContent = 'Inspecting…';
+ resultStatus.textContent = 'Calling GitHub…';
+
try {
- await creator.create(shouldFail);
- } catch {
- // The failure appears in the log panel with its stack trace.
+ renderRepository(await finder.find(repositoryInput.value));
+ } catch (error) {
+ renderFailure(error);
+ } finally {
+ submitButton.disabled = false;
+ submitButton.textContent = 'Inspect repository';
+ renderTelemetry();
}
-
- render();
}
-element('success').addEventListener('click', () => void run(false));
-element('failure').addEventListener('click', () => void run(true));
+form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ void inspectRepository();
+});
+
element('clear').addEventListener('click', () => {
metrics.clear();
- render();
+ renderTelemetry();
});
-render();
+renderTelemetry();
diff --git a/playground/src/style.css b/playground/src/style.css
index affe31c..7df8e34 100644
--- a/playground/src/style.css
+++ b/playground/src/style.css
@@ -30,7 +30,7 @@ main {
}
header {
- max-width: 760px;
+ max-width: 820px;
}
.eyebrow {
@@ -51,18 +51,68 @@ h1 {
}
.intro {
- max-width: 600px;
+ max-width: 690px;
margin: 28px 0 0;
color: #a7b0ba;
font-size: 19px;
line-height: 1.55;
}
-.controls {
+.demo {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
+ gap: 16px;
+ margin: 42px 0 16px;
+}
+
+.query-card,
+.result,
+.panel,
+.source-panel {
+ min-width: 0;
+ overflow: hidden;
+ border: 1px solid #272d35;
+ border-radius: 12px;
+ background: #101419;
+}
+
+.query-card,
+.result {
+ padding: 24px;
+}
+
+label {
+ display: block;
+ margin-bottom: 10px;
+ color: #a7b0ba;
+ font-size: 13px;
+ font-weight: 650;
+}
+
+.query-controls {
display: flex;
- flex-wrap: wrap;
gap: 10px;
- margin: 42px 0 30px;
+}
+
+input {
+ min-width: 0;
+ flex: 1;
+ padding: 12px 14px;
+ border: 1px solid #343b44;
+ border-radius: 8px;
+ outline: none;
+ color: #e8edf2;
+ background: #0b0e12;
+ font:
+ 14px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
+input:focus {
+ border-color: #71e5a7;
+ box-shadow: 0 0 0 3px rgb(113 229 167 / 12%);
}
button {
@@ -76,11 +126,16 @@ button {
cursor: pointer;
}
-button:hover {
+button:hover:not(:disabled) {
border-color: #66717e;
background: #1b2129;
}
+button:disabled {
+ cursor: wait;
+ opacity: 0.65;
+}
+
button.primary {
border-color: #71e5a7;
color: #08130d;
@@ -88,8 +143,81 @@ button.primary {
}
button.quiet {
+ padding: 5px 8px;
+ border: 0;
color: #98a3ae;
background: transparent;
+ font-size: 12px;
+}
+
+.query-card p,
+.result p {
+ margin: 14px 0 0;
+ color: #7f8a96;
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.result {
+ box-shadow: inset 3px 0 0 transparent;
+}
+
+.result-success {
+ box-shadow: inset 3px 0 0 #71e5a7;
+}
+
+.result-failure {
+ box-shadow: inset 3px 0 0 #ff7474;
+}
+
+.result-status {
+ display: block;
+ margin-bottom: 8px;
+ color: #71e5a7;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.result-failure .result-status {
+ color: #ff7474;
+}
+
+.result > a {
+ color: #e8edf2;
+ font-size: 23px;
+ font-weight: 650;
+ text-decoration: none;
+}
+
+.result > a[href]:hover {
+ color: #71e5a7;
+}
+
+.result dl {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin: 22px 0 0;
+}
+
+.result dl div {
+ min-width: 0;
+}
+
+.result dt {
+ color: #68737f;
+ font-size: 11px;
+ text-transform: uppercase;
+}
+
+.result dd {
+ margin: 5px 0 0;
+ overflow: hidden;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.stats {
@@ -127,19 +255,12 @@ button.quiet {
margin-top: 16px;
}
-.panel {
- min-width: 0;
- overflow: hidden;
- border: 1px solid #272d35;
- border-radius: 12px;
- background: #101419;
-}
-
.panel-title {
display: flex;
+ min-height: 54px;
align-items: center;
justify-content: space-between;
- padding: 16px 18px;
+ padding: 14px 18px;
border-bottom: 1px solid #272d35;
}
@@ -153,36 +274,150 @@ h2 {
font-size: 12px;
}
-pre {
+.event-list {
min-height: 360px;
- max-height: 540px;
+ max-height: 560px;
margin: 0;
- padding: 18px;
+ padding: 0;
overflow: auto;
- color: #b9c5d1;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ list-style: none;
+}
+
+.event,
+.empty-event {
+ padding: 15px 18px;
+ border-bottom: 1px solid #20262d;
+}
+
+.empty-event {
+ color: #68737f;
+ font-size: 13px;
+}
+
+.event-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.event-heading strong {
+ overflow: hidden;
+ color: #cbd5df;
+ font:
+ 12px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.event time {
+ flex: none;
+ color: #59636e;
+ font:
+ 11px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
+.event-value,
+.log-message {
+ display: block;
+ margin-top: 8px;
+ color: #e8edf2;
+ font:
+ 14px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
+.log-level {
+ color: #71e5a7;
+ font:
+ 11px ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+ text-transform: uppercase;
+}
+
+.log-error .log-level {
+ color: #ff7474;
+}
+
+details {
+ margin-top: 12px;
+}
+
+summary {
+ color: #98a3ae;
font-size: 12px;
- line-height: 1.6;
+ cursor: pointer;
+}
+
+details pre {
+ max-height: 240px;
+ margin: 10px 0 0;
+ padding: 12px;
+ overflow: auto;
+ border-radius: 8px;
+ color: #ffb2b2;
+ background: #0b0e12;
+ font:
+ 11px/1.55 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
white-space: pre-wrap;
}
+.source-panel {
+ margin-top: 16px;
+}
+
+.source-panel > pre {
+ margin: 0;
+ padding: 20px;
+ overflow: auto;
+ color: #b9c5d1;
+ background: #0d1116;
+ font:
+ 13px/1.65 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ monospace;
+}
+
footer {
margin-top: 24px;
color: #68737f;
font-size: 13px;
}
+@media (max-width: 860px) {
+ .demo,
+ .panels {
+ grid-template-columns: 1fr;
+ }
+}
+
@media (max-width: 760px) {
main {
width: min(100% - 24px, 1120px);
padding-top: 42px;
}
- .stats {
- grid-template-columns: 1fr 1fr;
+ .query-controls {
+ align-items: stretch;
+ flex-direction: column;
}
- .panels {
- grid-template-columns: 1fr;
+ .stats,
+ .result dl {
+ grid-template-columns: 1fr 1fr;
}
}
diff --git a/src/instrumentation/MetricsInstrumenter.ts b/src/instrumentation/MetricsInstrumenter.ts
index 11d8d43..bdc1458 100644
--- a/src/instrumentation/MetricsInstrumenter.ts
+++ b/src/instrumentation/MetricsInstrumenter.ts
@@ -7,7 +7,8 @@ import type { DecoratedMethod } from './DecoratedMethod.js';
import type { InstrumentationDependencies } from './InstrumentationDependencies.js';
import type { InstrumentationOptions } from './InstrumentationOptions.js';
-import { NodeResourceUsageAdapter, SystemClock } from '../adapters/index.js';
+import { NodeResourceUsageAdapter } from '../adapters/node/NodeResourceUsageAdapter.js';
+import { SystemClock } from '../adapters/system/SystemClock.js';
import { InstrumentationExecution } from './InstrumentationExecution.js';
import { NoopLoggerAdapter } from './NoopLoggerAdapter.js';
import { NoopMetricsAdapter } from './NoopMetricsAdapter.js';