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.

-
- - - +
+
+ +
+ + +
+

+ Change the value to a missing repository to capture a real HTTP + failure and stack trace. +

+
+ +
+ No request yet + Run the decorated method +

+ The result will come from GitHub's public API. +

+
+
+
Stars
+
+
+
+
Forks
+
+
+
+
Open issues
+
+
+
+
Updated
+
+
+
+
Calls0
Failures0
- Avg. duration0 ms + Last duration0 ms
-
Buffered0
+
Logs0
@@ -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

    +
    -
    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();
      +  }
      +}
      +
      +
      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';