Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

Compare changes

Choose any two refs to compare.

+72
.env.grafana.example
···
+
# Grafana Cloud Configuration for wisp.place monorepo
+
# Copy these variables to your .env file to enable Grafana integration
+
# The observability package will automatically pick up these environment variables
+
+
# ============================================================================
+
# Grafana Loki (for logs)
+
# ============================================================================
+
# Get this from your Grafana Cloud portal under Loki → Details
+
# Example: https://logs-prod-012.grafana.net
+
GRAFANA_LOKI_URL=https://logs-prod-xxx.grafana.net
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_LOKI_TOKEN=glc_xxx
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
# GRAFANA_LOKI_USERNAME=your-username
+
# GRAFANA_LOKI_PASSWORD=your-password
+
+
# ============================================================================
+
# Grafana Prometheus (for metrics)
+
# ============================================================================
+
# Get this from your Grafana Cloud portal under Prometheus → Details
+
# Note: You need to add /api/prom to the base URL for OTLP export
+
# Example: https://prometheus-prod-10-prod-us-central-0.grafana.net/api/prom
+
GRAFANA_PROMETHEUS_URL=https://prometheus-prod-xxx.grafana.net/api/prom
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_PROMETHEUS_TOKEN=glc_xxx
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
# GRAFANA_PROMETHEUS_USERNAME=your-username
+
# GRAFANA_PROMETHEUS_PASSWORD=your-password
+
+
# ============================================================================
+
# Optional Configuration
+
# ============================================================================
+
# These will be used by both main-app and hosting-service if not overridden
+
+
# Service metadata (optional - defaults are provided in code)
+
# SERVICE_NAME=wisp-app
+
# SERVICE_VERSION=1.0.0
+
+
# Batching configuration (optional)
+
# GRAFANA_BATCH_SIZE=100 # Flush after this many entries
+
# GRAFANA_FLUSH_INTERVAL=5000 # Flush every 5 seconds
+
+
# ============================================================================
+
# How to get these values:
+
# ============================================================================
+
# 1. Sign up for Grafana Cloud at https://grafana.com/
+
# 2. Go to your Grafana Cloud portal
+
# 3. For Loki:
+
# - Navigate to "Connections" → "Loki"
+
# - Click "Details"
+
# - Copy the Push endpoint URL (without /loki/api/v1/push)
+
# - Create an API token with push permissions
+
# 4. For Prometheus:
+
# - Navigate to "Connections" → "Prometheus"
+
# - Click "Details"
+
# - Copy the Remote Write endpoint (add /api/prom for OTLP)
+
# - Create an API token with write permissions
+
+
# ============================================================================
+
# Testing the integration:
+
# ============================================================================
+
# 1. Copy this file's contents to your .env file
+
# 2. Fill in the actual values
+
# 3. Restart your services (main-app and hosting-service)
+
# 4. Check your Grafana Cloud dashboard for incoming data
+
# 5. Use Grafana Explore to query:
+
# - Loki: {job="main-app"} or {job="hosting-service"}
+
# - Prometheus: http_requests_total{service="main-app"}
+7 -1
apps/hosting-service/src/index.ts
···
import app from './server';
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
-
import { createLogger } from '@wisp/observability';
+
import { createLogger, initializeGrafanaExporters } from '@wisp/observability';
import { mkdirSync, existsSync } from 'fs';
import { backfillCache } from './lib/backfill';
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
+
+
// Initialize Grafana exporters if configured
+
initializeGrafanaExporters({
+
serviceName: 'hosting-service',
+
serviceVersion: '1.0.0'
+
});
const logger = createLogger('hosting-service');
+473 -8
apps/hosting-service/src/lib/utils.test.ts
···
import { describe, test, expect } from 'bun:test'
-
import { sanitizePath, extractBlobCid } from './utils'
+
import { sanitizePath, extractBlobCid, extractSubfsUris, expandSubfsNodes } from './utils'
import { CID } from 'multiformats'
+
import { BlobRef } from '@atproto/lexicon'
+
import type {
+
Record as WispFsRecord,
+
Directory as FsDirectory,
+
Entry as FsEntry,
+
File as FsFile,
+
Subfs as FsSubfs,
+
} from '@wisp/lexicons/types/place/wisp/fs'
+
import type {
+
Record as SubfsRecord,
+
Directory as SubfsDirectory,
+
Entry as SubfsEntry,
+
File as SubfsFile,
+
Subfs as SubfsSubfs,
+
} from '@wisp/lexicons/types/place/wisp/subfs'
+
import type { $Typed } from '@wisp/lexicons/util'
describe('sanitizePath', () => {
test('allows normal file paths', () => {
···
test('blocks directory traversal in middle of path', () => {
expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd')
-
// Note: sanitizePath only filters out ".." segments, doesn't resolve paths
expect(sanitizePath('a/b/../c')).toBe('a/b/c')
expect(sanitizePath('a/../b/../c')).toBe('a/b/c')
})
···
})
test('blocks null bytes', () => {
-
// Null bytes cause the entire segment to be filtered out
expect(sanitizePath('index.html\0.txt')).toBe('')
expect(sanitizePath('test\0')).toBe('')
-
// Null byte in middle segment
expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css')
})
···
describe('extractBlobCid', () => {
const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
-
+
test('extracts CID from IPLD link', () => {
const blobRef = { $link: TEST_CID }
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
···
})
test('extracts CID from typed BlobRef with IPLD link', () => {
-
const blobRef = {
+
const blobRef = {
ref: { $link: TEST_CID }
}
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
···
})
test('handles nested structures from AT Proto API', () => {
-
// Real structure from AT Proto
const blobRef = {
$type: 'blob',
ref: CID.parse(TEST_CID),
···
})
test('prioritizes checking IPLD link first', () => {
-
// Direct $link takes precedence
const directLink = { $link: TEST_CID }
expect(extractBlobCid(directLink)).toBe(TEST_CID)
})
···
expect(extractBlobCid(blobRef)).toBe(cidV1)
})
})
+
+
const TEST_CID_BASE = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
+
+
function createMockBlobRef(cidSuffix: string = '', size: number = 100, mimeType: string = 'text/plain'): BlobRef {
+
const cidString = TEST_CID_BASE
+
return new BlobRef(CID.parse(cidString), mimeType, size)
+
}
+
+
function createFsFile(
+
name: string,
+
options: { mimeType?: string; size?: number; encoding?: 'gzip'; base64?: boolean } = {}
+
): FsEntry {
+
const { mimeType = 'text/plain', size = 100, encoding, base64 } = options
+
const file: $Typed<FsFile, 'place.wisp.fs#file'> = {
+
$type: 'place.wisp.fs#file',
+
type: 'file',
+
blob: createMockBlobRef(name.replace(/[^a-z0-9]/gi, ''), size, mimeType),
+
...(encoding && { encoding }),
+
...(mimeType && { mimeType }),
+
...(base64 && { base64 }),
+
}
+
return { name, node: file }
+
}
+
+
function createFsDirectory(name: string, entries: FsEntry[]): FsEntry {
+
const dir: $Typed<FsDirectory, 'place.wisp.fs#directory'> = {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries,
+
}
+
return { name, node: dir }
+
}
+
+
function createFsSubfs(name: string, subject: string, flat: boolean = true): FsEntry {
+
const subfs: $Typed<FsSubfs, 'place.wisp.fs#subfs'> = {
+
$type: 'place.wisp.fs#subfs',
+
type: 'subfs',
+
subject,
+
flat,
+
}
+
return { name, node: subfs }
+
}
+
+
function createFsRootDirectory(entries: FsEntry[]): FsDirectory {
+
return {
+
$type: 'place.wisp.fs#directory',
+
type: 'directory',
+
entries,
+
}
+
}
+
+
function createFsRecord(site: string, entries: FsEntry[], fileCount?: number): WispFsRecord {
+
return {
+
$type: 'place.wisp.fs',
+
site,
+
root: createFsRootDirectory(entries),
+
...(fileCount !== undefined && { fileCount }),
+
createdAt: new Date().toISOString(),
+
}
+
}
+
+
function createSubfsFile(
+
name: string,
+
options: { mimeType?: string; size?: number; encoding?: 'gzip'; base64?: boolean } = {}
+
): SubfsEntry {
+
const { mimeType = 'text/plain', size = 100, encoding, base64 } = options
+
const file: $Typed<SubfsFile, 'place.wisp.subfs#file'> = {
+
$type: 'place.wisp.subfs#file',
+
type: 'file',
+
blob: createMockBlobRef(name.replace(/[^a-z0-9]/gi, ''), size, mimeType),
+
...(encoding && { encoding }),
+
...(mimeType && { mimeType }),
+
...(base64 && { base64 }),
+
}
+
return { name, node: file }
+
}
+
+
function createSubfsDirectory(name: string, entries: SubfsEntry[]): SubfsEntry {
+
const dir: $Typed<SubfsDirectory, 'place.wisp.subfs#directory'> = {
+
$type: 'place.wisp.subfs#directory',
+
type: 'directory',
+
entries,
+
}
+
return { name, node: dir }
+
}
+
+
function createSubfsSubfs(name: string, subject: string): SubfsEntry {
+
const subfs: $Typed<SubfsSubfs, 'place.wisp.subfs#subfs'> = {
+
$type: 'place.wisp.subfs#subfs',
+
type: 'subfs',
+
subject,
+
}
+
return { name, node: subfs }
+
}
+
+
function createSubfsRootDirectory(entries: SubfsEntry[]): SubfsDirectory {
+
return {
+
$type: 'place.wisp.subfs#directory',
+
type: 'directory',
+
entries,
+
}
+
}
+
+
function createSubfsRecord(entries: SubfsEntry[], fileCount?: number): SubfsRecord {
+
return {
+
$type: 'place.wisp.subfs',
+
root: createSubfsRootDirectory(entries),
+
...(fileCount !== undefined && { fileCount }),
+
createdAt: new Date().toISOString(),
+
}
+
}
+
+
describe('extractSubfsUris', () => {
+
test('extracts subfs URIs from flat directory structure', () => {
+
const subfsUri = 'at://did:plc:test/place.wisp.subfs/a'
+
const dir = createFsRootDirectory([
+
createFsSubfs('a', subfsUri),
+
createFsFile('file.txt'),
+
])
+
+
const uris = extractSubfsUris(dir)
+
+
expect(uris).toHaveLength(1)
+
expect(uris[0]).toEqual({ uri: subfsUri, path: 'a' })
+
})
+
+
test('extracts subfs URIs from nested directory structure', () => {
+
const subfsAUri = 'at://did:plc:test/place.wisp.subfs/a'
+
const subfsBUri = 'at://did:plc:test/place.wisp.subfs/b'
+
+
const dir = createFsRootDirectory([
+
createFsSubfs('a', subfsAUri),
+
createFsDirectory('nested', [
+
createFsSubfs('b', subfsBUri),
+
createFsFile('file.txt'),
+
]),
+
])
+
+
const uris = extractSubfsUris(dir)
+
+
expect(uris).toHaveLength(2)
+
expect(uris).toContainEqual({ uri: subfsAUri, path: 'a' })
+
expect(uris).toContainEqual({ uri: subfsBUri, path: 'nested/b' })
+
})
+
+
test('returns empty array when no subfs nodes exist', () => {
+
const dir = createFsRootDirectory([
+
createFsFile('file1.txt'),
+
createFsDirectory('dir', [createFsFile('file2.txt')]),
+
])
+
+
const uris = extractSubfsUris(dir)
+
expect(uris).toHaveLength(0)
+
})
+
+
test('handles deeply nested subfs', () => {
+
const subfsUri = 'at://did:plc:test/place.wisp.subfs/deep'
+
const dir = createFsRootDirectory([
+
createFsDirectory('a', [
+
createFsDirectory('b', [
+
createFsDirectory('c', [
+
createFsSubfs('deep', subfsUri),
+
]),
+
]),
+
]),
+
])
+
+
const uris = extractSubfsUris(dir)
+
+
expect(uris).toHaveLength(1)
+
expect(uris[0]).toEqual({ uri: subfsUri, path: 'a/b/c/deep' })
+
})
+
})
+
+
describe('expandSubfsNodes caching', () => {
+
test('cache map is populated after expansion', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const dir = createFsRootDirectory([createFsFile('file.txt')])
+
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(subfsCache.size).toBe(0)
+
expect(result.entries).toHaveLength(1)
+
expect(result.entries[0]?.name).toBe('file.txt')
+
})
+
+
test('cache is passed through recursion depths', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const mockSubfsUri = 'at://did:plc:test/place.wisp.subfs/cached'
+
const mockRecord = createSubfsRecord([createSubfsFile('cached-file.txt')])
+
subfsCache.set(mockSubfsUri, mockRecord)
+
+
const dir = createFsRootDirectory([createFsSubfs('cached', mockSubfsUri)])
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(subfsCache.has(mockSubfsUri)).toBe(true)
+
expect(result.entries).toHaveLength(1)
+
expect(result.entries[0]?.name).toBe('cached-file.txt')
+
})
+
+
test('pre-populated cache prevents re-fetching', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const subfsAUri = 'at://did:plc:test/place.wisp.subfs/a'
+
const subfsBUri = 'at://did:plc:test/place.wisp.subfs/b'
+
+
subfsCache.set(subfsAUri, createSubfsRecord([createSubfsSubfs('b', subfsBUri)]))
+
subfsCache.set(subfsBUri, createSubfsRecord([createSubfsFile('final.txt')]))
+
+
const dir = createFsRootDirectory([createFsSubfs('a', subfsAUri)])
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(result.entries).toHaveLength(1)
+
expect(result.entries[0]?.name).toBe('final.txt')
+
})
+
+
test('diamond dependency uses cache for shared reference', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const subfsAUri = 'at://did:plc:test/place.wisp.subfs/a'
+
const subfsBUri = 'at://did:plc:test/place.wisp.subfs/b'
+
const subfsCUri = 'at://did:plc:test/place.wisp.subfs/c'
+
+
subfsCache.set(subfsAUri, createSubfsRecord([createSubfsSubfs('c', subfsCUri)]))
+
subfsCache.set(subfsBUri, createSubfsRecord([createSubfsSubfs('c', subfsCUri)]))
+
subfsCache.set(subfsCUri, createSubfsRecord([createSubfsFile('shared.txt')]))
+
+
const dir = createFsRootDirectory([
+
createFsSubfs('a', subfsAUri),
+
createFsSubfs('b', subfsBUri),
+
])
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(result.entries.filter(e => e.name === 'shared.txt')).toHaveLength(2)
+
})
+
+
test('handles null records in cache gracefully', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const subfsUri = 'at://did:plc:test/place.wisp.subfs/missing'
+
subfsCache.set(subfsUri, null)
+
+
const dir = createFsRootDirectory([
+
createFsFile('file.txt'),
+
createFsSubfs('missing', subfsUri),
+
])
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(result.entries.some(e => e.name === 'file.txt')).toBe(true)
+
expect(result.entries.some(e => e.name === 'missing')).toBe(true)
+
})
+
+
test('non-flat subfs merge creates directory instead of hoisting', async () => {
+
const subfsCache = new Map<string, SubfsRecord | null>()
+
const subfsUri = 'at://did:plc:test/place.wisp.subfs/nested'
+
subfsCache.set(subfsUri, createSubfsRecord([createSubfsFile('nested-file.txt')]))
+
+
const dir = createFsRootDirectory([
+
createFsFile('root.txt'),
+
createFsSubfs('subdir', subfsUri, false),
+
])
+
const result = await expandSubfsNodes(dir, 'https://pds.example.com', 0, subfsCache)
+
+
expect(result.entries).toHaveLength(2)
+
+
const rootFile = result.entries.find(e => e.name === 'root.txt')
+
expect(rootFile).toBeDefined()
+
+
const subdir = result.entries.find(e => e.name === 'subdir')
+
expect(subdir).toBeDefined()
+
+
if (subdir && 'entries' in subdir.node) {
+
expect(subdir.node.type).toBe('directory')
+
expect(subdir.node.entries).toHaveLength(1)
+
expect(subdir.node.entries[0]?.name).toBe('nested-file.txt')
+
}
+
})
+
})
+
+
describe('WispFsRecord mock builders', () => {
+
test('createFsRecord creates valid record structure', () => {
+
const record = createFsRecord('my-site', [
+
createFsFile('index.html', { mimeType: 'text/html' }),
+
createFsDirectory('assets', [
+
createFsFile('style.css', { mimeType: 'text/css' }),
+
]),
+
])
+
+
expect(record.$type).toBe('place.wisp.fs')
+
expect(record.site).toBe('my-site')
+
expect(record.root.type).toBe('directory')
+
expect(record.root.entries).toHaveLength(2)
+
expect(record.createdAt).toBeDefined()
+
})
+
+
test('createFsFile creates valid file entry', () => {
+
const entry = createFsFile('test.html', { mimeType: 'text/html', size: 500 })
+
+
expect(entry.name).toBe('test.html')
+
+
const file = entry.node
+
if ('blob' in file) {
+
expect(file.$type).toBe('place.wisp.fs#file')
+
expect(file.type).toBe('file')
+
expect(file.blob).toBeDefined()
+
expect(file.mimeType).toBe('text/html')
+
}
+
})
+
+
test('createFsFile with gzip encoding', () => {
+
const entry = createFsFile('bundle.js', { mimeType: 'application/javascript', encoding: 'gzip' })
+
+
const file = entry.node
+
if ('encoding' in file) {
+
expect(file.encoding).toBe('gzip')
+
}
+
})
+
+
test('createFsFile with base64 flag', () => {
+
const entry = createFsFile('data.bin', { base64: true })
+
+
const file = entry.node
+
if ('base64' in file) {
+
expect(file.base64).toBe(true)
+
}
+
})
+
+
test('createFsDirectory creates valid directory entry', () => {
+
const entry = createFsDirectory('assets', [
+
createFsFile('file1.txt'),
+
createFsFile('file2.txt'),
+
])
+
+
expect(entry.name).toBe('assets')
+
+
const dir = entry.node
+
if ('entries' in dir) {
+
expect(dir.$type).toBe('place.wisp.fs#directory')
+
expect(dir.type).toBe('directory')
+
expect(dir.entries).toHaveLength(2)
+
}
+
})
+
+
test('createFsSubfs creates valid subfs entry with flat=true', () => {
+
const entry = createFsSubfs('external', 'at://did:plc:test/place.wisp.subfs/ext')
+
+
expect(entry.name).toBe('external')
+
+
const subfs = entry.node
+
if ('subject' in subfs) {
+
expect(subfs.$type).toBe('place.wisp.fs#subfs')
+
expect(subfs.type).toBe('subfs')
+
expect(subfs.subject).toBe('at://did:plc:test/place.wisp.subfs/ext')
+
expect(subfs.flat).toBe(true)
+
}
+
})
+
+
test('createFsSubfs creates valid subfs entry with flat=false', () => {
+
const entry = createFsSubfs('external', 'at://did:plc:test/place.wisp.subfs/ext', false)
+
+
const subfs = entry.node
+
if ('subject' in subfs) {
+
expect(subfs.flat).toBe(false)
+
}
+
})
+
+
test('createFsRecord with fileCount', () => {
+
const record = createFsRecord('my-site', [createFsFile('index.html')], 1)
+
expect(record.fileCount).toBe(1)
+
})
+
})
+
+
describe('SubfsRecord mock builders', () => {
+
test('createSubfsRecord creates valid record structure', () => {
+
const record = createSubfsRecord([
+
createSubfsFile('file1.txt'),
+
createSubfsDirectory('nested', [
+
createSubfsFile('file2.txt'),
+
]),
+
])
+
+
expect(record.$type).toBe('place.wisp.subfs')
+
expect(record.root.type).toBe('directory')
+
expect(record.root.entries).toHaveLength(2)
+
expect(record.createdAt).toBeDefined()
+
})
+
+
test('createSubfsFile creates valid file entry', () => {
+
const entry = createSubfsFile('data.json', { mimeType: 'application/json', size: 1024 })
+
+
expect(entry.name).toBe('data.json')
+
+
const file = entry.node
+
if ('blob' in file) {
+
expect(file.$type).toBe('place.wisp.subfs#file')
+
expect(file.type).toBe('file')
+
expect(file.blob).toBeDefined()
+
expect(file.mimeType).toBe('application/json')
+
}
+
})
+
+
test('createSubfsDirectory creates valid directory entry', () => {
+
const entry = createSubfsDirectory('subdir', [createSubfsFile('inner.txt')])
+
+
expect(entry.name).toBe('subdir')
+
+
const dir = entry.node
+
if ('entries' in dir) {
+
expect(dir.$type).toBe('place.wisp.subfs#directory')
+
expect(dir.type).toBe('directory')
+
expect(dir.entries).toHaveLength(1)
+
}
+
})
+
+
test('createSubfsSubfs creates valid nested subfs entry', () => {
+
const entry = createSubfsSubfs('deeper', 'at://did:plc:test/place.wisp.subfs/deeper')
+
+
expect(entry.name).toBe('deeper')
+
+
const subfs = entry.node
+
if ('subject' in subfs) {
+
expect(subfs.$type).toBe('place.wisp.subfs#subfs')
+
expect(subfs.type).toBe('subfs')
+
expect(subfs.subject).toBe('at://did:plc:test/place.wisp.subfs/deeper')
+
expect('flat' in subfs).toBe(false)
+
}
+
})
+
+
test('createSubfsRecord with fileCount', () => {
+
const record = createSubfsRecord([createSubfsFile('file.txt')], 1)
+
expect(record.fileCount).toBe(1)
+
})
+
})
+
+
describe('extractBlobCid with typed mock data', () => {
+
test('extracts CID from FsFile blob', () => {
+
const entry = createFsFile('test.txt')
+
const file = entry.node
+
+
if ('blob' in file) {
+
const cid = extractBlobCid(file.blob)
+
expect(cid).toBeDefined()
+
expect(cid).toContain('bafkrei')
+
}
+
})
+
+
test('extracts CID from SubfsFile blob', () => {
+
const entry = createSubfsFile('test.txt')
+
const file = entry.node
+
+
if ('blob' in file) {
+
const cid = extractBlobCid(file.blob)
+
expect(cid).toBeDefined()
+
expect(cid).toContain('bafkrei')
+
}
+
})
+
})
+33 -13
apps/hosting-service/src/lib/utils.ts
···
/**
* Extract all subfs URIs from a directory tree with their mount paths
*/
-
function extractSubfsUris(directory: Directory, currentPath: string = ''): Array<{ uri: string; path: string }> {
+
export function extractSubfsUris(directory: Directory, currentPath: string = ''): Array<{ uri: string; path: string }> {
const uris: Array<{ uri: string; path: string }> = [];
for (const entry of directory.entries) {
···
* Replace subfs nodes in a directory tree with their actual content
* Subfs entries are "merged" - their root entries are hoisted into the parent directory
* This function is recursive - it will keep expanding until no subfs nodes remain
+
* Uses a cache to avoid re-fetching the same subfs records across recursion depths
*/
-
async function expandSubfsNodes(directory: Directory, pdsEndpoint: string, depth: number = 0): Promise<Directory> {
+
export async function expandSubfsNodes(
+
directory: Directory,
+
pdsEndpoint: string,
+
depth: number = 0,
+
subfsCache: Map<string, SubfsRecord | null> = new Map()
+
): Promise<Directory> {
const MAX_DEPTH = 10; // Prevent infinite loops
if (depth >= MAX_DEPTH) {
···
return directory;
}
-
console.log(`[Depth ${depth}] Found ${subfsUris.length} subfs records, fetching...`);
+
// Filter to only URIs we haven't fetched yet
+
const uncachedUris = subfsUris.filter(({ uri }) => !subfsCache.has(uri));
-
// Fetch all subfs records in parallel
-
const subfsRecords = await Promise.all(
-
subfsUris.map(async ({ uri, path }) => {
-
const record = await fetchSubfsRecord(uri, pdsEndpoint);
-
return { record, path };
-
})
-
);
+
if (uncachedUris.length > 0) {
+
console.log(`[Depth ${depth}] Found ${subfsUris.length} subfs references, fetching ${uncachedUris.length} new records (${subfsUris.length - uncachedUris.length} cached)...`);
-
// Build a map of path -> root entries to merge
+
// Fetch only uncached subfs records in parallel
+
const fetchedRecords = await Promise.all(
+
uncachedUris.map(async ({ uri }) => {
+
const record = await fetchSubfsRecord(uri, pdsEndpoint);
+
return { uri, record };
+
})
+
);
+
+
// Add fetched records to cache
+
for (const { uri, record } of fetchedRecords) {
+
subfsCache.set(uri, record);
+
}
+
} else {
+
console.log(`[Depth ${depth}] Found ${subfsUris.length} subfs references, all cached`);
+
}
+
+
// Build a map of path -> root entries to merge using the cache
// Note: SubFS entries are compatible with FS entries at runtime
const subfsMap = new Map<string, Entry[]>();
-
for (const { record, path } of subfsRecords) {
+
for (const { uri, path } of subfsUris) {
+
const record = subfsCache.get(uri);
if (record && record.root && record.root.entries) {
subfsMap.set(path, record.root.entries as unknown as Entry[]);
}
···
};
// Recursively expand any remaining subfs nodes (e.g., nested subfs inside parent subfs)
-
return expandSubfsNodes(partiallyExpanded, pdsEndpoint, depth + 1);
+
// Pass the cache to avoid re-fetching records
+
return expandSubfsNodes(partiallyExpanded, pdsEndpoint, depth + 1, subfsCache);
}
+6 -5
apps/main-app/package.json
···
"screenshot": "bun run scripts/screenshot-sites.ts"
},
"dependencies": {
-
"@atproto/api": "^0.17.3",
-
"@atproto/common-web": "^0.4.5",
+
"@atproto-labs/did-resolver": "^0.2.4",
+
"@atproto/api": "^0.17.7",
+
"@atproto/common-web": "^0.4.6",
"@atproto/jwk-jose": "^0.1.11",
-
"@atproto/lex-cli": "^0.9.5",
-
"@atproto/oauth-client-node": "^0.3.9",
-
"@atproto/xrpc-server": "^0.9.5",
+
"@atproto/lex-cli": "^0.9.7",
+
"@atproto/oauth-client-node": "^0.3.12",
+
"@atproto/xrpc-server": "^0.9.6",
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
+7 -1
apps/main-app/src/index.ts
···
import { siteRoutes } from './routes/site'
import { csrfProtection } from './lib/csrf'
import { DNSVerificationWorker } from './lib/dns-verification-worker'
-
import { createLogger, logCollector } from '@wisp/observability'
+
import { createLogger, logCollector, initializeGrafanaExporters } from '@wisp/observability'
import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
import { promptAdminSetup } from './lib/admin-auth'
import { adminRoutes } from './routes/admin'
+
+
// Initialize Grafana exporters if configured
+
initializeGrafanaExporters({
+
serviceName: 'main-app',
+
serviceVersion: '1.0.50'
+
})
const logger = createLogger('main-app')
+5 -4
apps/main-app/src/lib/oauth-client.ts
···
import { logger } from "./logger";
import { SlingshotHandleResolver } from "./slingshot-handle-resolver";
+
// OAuth scope for all client types
+
const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/*';
// Session timeout configuration (30 days in seconds)
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
// OAuth state timeout (1 hour in seconds)
···
// Loopback client for local development
// For loopback, scopes and redirect_uri must be in client_id query string
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
-
const scope = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview';
const params = new URLSearchParams();
params.append('redirect_uri', redirectUri);
-
params.append('scope', scope);
+
params.append('scope', OAUTH_SCOPE);
return {
client_id: `http://localhost?${params.toString()}`,
···
response_types: ['code'],
application_type: 'web',
token_endpoint_auth_method: 'none',
-
scope: scope,
+
scope: OAUTH_SCOPE,
dpop_bound_access_tokens: false,
subject_type: 'public',
authorization_signed_response_alg: 'ES256'
···
application_type: 'web',
token_endpoint_auth_method: 'private_key_jwt',
token_endpoint_auth_signing_alg: "ES256",
-
scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview",
+
scope: OAUTH_SCOPE,
dpop_bound_access_tokens: true,
jwks_uri: `${config.domain}/jwks.json`,
subject_type: 'public',
+10 -12
apps/main-app/src/routes/user.ts
···
import { Elysia, t } from 'elysia'
import { requireAuth } from '../lib/wisp-auth'
import { NodeOAuthClient } from '@atproto/oauth-client-node'
-
import { Agent } from '@atproto/api'
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { createLogger } from '@wisp/observability'
+
import { createDidResolver, extractAtprotoData } from '@atproto-labs/did-resolver'
const logger = createLogger('main-app')
+
const didResolver = createDidResolver({})
export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
new Elysia({
···
})
.get('/info', async ({ auth }) => {
try {
-
// Get user's handle from AT Protocol
-
const agent = new Agent(auth.session)
-
let handle = 'unknown'
try {
-
console.log('[User] Attempting to fetch profile for DID:', auth.did)
-
const profile = await agent.getProfile({ actor: auth.did })
-
console.log('[User] Profile fetched successfully:', profile.data.handle)
-
handle = profile.data.handle
+
const didDoc = await didResolver.resolve(auth.did)
+
const atprotoData = extractAtprotoData(didDoc)
+
+
if (atprotoData.aka) {
+
handle = atprotoData.aka
+
}
} catch (err) {
-
console.error('[User] Failed to fetch profile - Full error:', err)
-
console.error('[User] Error message:', err instanceof Error ? err.message : String(err))
-
console.error('[User] Error stack:', err instanceof Error ? err.stack : 'No stack')
-
logger.error('[User] Failed to fetch profile', err)
+
+
logger.error('[User] Failed to resolve DID', err)
}
return {
+189 -13
bun.lock
···
"@tailwindcss/cli": "^4.1.17",
"atproto-ui": "^0.12.0",
"bun-plugin-tailwind": "^0.1.2",
+
"elysia": "^1.4.18",
"tailwindcss": "^4.1.17",
},
},
···
"name": "@wisp/main-app",
"version": "1.0.50",
"dependencies": {
-
"@atproto/api": "^0.17.3",
-
"@atproto/common-web": "^0.4.5",
+
"@atproto-labs/did-resolver": "^0.2.4",
+
"@atproto/api": "^0.17.7",
+
"@atproto/common-web": "^0.4.6",
"@atproto/jwk-jose": "^0.1.11",
-
"@atproto/lex-cli": "^0.9.5",
-
"@atproto/oauth-client-node": "^0.3.9",
-
"@atproto/xrpc-server": "^0.9.5",
+
"@atproto/lex-cli": "^0.9.7",
+
"@atproto/oauth-client-node": "^0.3.12",
+
"@atproto/xrpc-server": "^0.9.6",
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
···
"packages/@wisp/observability": {
"name": "@wisp/observability",
"version": "1.0.0",
+
"dependencies": {
+
"@opentelemetry/api": "^1.9.0",
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.56.0",
+
"@opentelemetry/resources": "^1.29.0",
+
"@opentelemetry/sdk-metrics": "^1.29.0",
+
"@opentelemetry/semantic-conventions": "^1.29.0",
+
},
+
"devDependencies": {
+
"@hono/node-server": "^1.19.6",
+
"bun-types": "^1.3.3",
+
"typescript": "^5.9.3",
+
},
"peerDependencies": {
-
"hono": "^4.0.0",
+
"hono": "^4.10.7",
},
"optionalPeers": [
"hono",
···
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
-
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
"@opentelemetry/core": ["@opentelemetry/core@1.29.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-gmT7vAreXl0DTHD2rVZcw3+l2g84+5XiHIqdBUxXbExymPCvSsGOpiwMmn8nkiJur28STV31wnhIDrzWDPzjfA=="],
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
···
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
-
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
+
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.56.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/otlp-exporter-base": "0.56.0", "@opentelemetry/otlp-transformer": "0.56.0", "@opentelemetry/resources": "1.29.0", "@opentelemetry/sdk-metrics": "1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GD5QuCT6js+mDpb5OBO6OSyCH+k2Gy3xPHJV9BnjV8W6kpSuY8y2Samzs5vl23UcGMq6sHLAbs+Eq/VYsLMiVw=="],
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
···
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
-
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.56.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/otlp-transformer": "0.56.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-eURvv0fcmBE+KE1McUeRo+u0n18ZnUeSc7lDlW/dzlqFYasEbsztTK4v0Qf8C4vEY+aMTjPKUxBG0NX2Te3Pmw=="],
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
-
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.56.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.56.0", "@opentelemetry/core": "1.29.0", "@opentelemetry/resources": "1.29.0", "@opentelemetry/sdk-logs": "0.56.0", "@opentelemetry/sdk-metrics": "1.29.0", "@opentelemetry/sdk-trace-base": "1.29.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kVkH/W2W7EpgWWpyU5VnnjIdSD7Y7FljQYObAQSKdRcejiwMj2glypZtUdfq1LTJcv4ht0jyTrw1D3CCxssNtQ=="],
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
-
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
"@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
-
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
···
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
-
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
+
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
···
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@1.29.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-s7mLXuHZE7RQr1wwweGcaRp3Q4UJJ0wazeGlc/N5/XSe6UyXfsh1UQGMADYeg7YwD+cEdMtU1yJAUXdnFzYzyQ=="],
+
+
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.29.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/resources": "1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MkVtuzDjXZaUJSuJlHn6BSXjcQlMvHcsDV7LjY4P6AJeffMa4+kIGDjzsCf6DkAh6Vqlwag5EWEam3KZOX5Drw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-prometheus/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-prometheus/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-prometheus/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-zipkin/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-zipkin/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.56.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wr39+94UNNG3Ei9nv3pHd4AJ63gq5nSemMRpCd8fPwDL9rN3vK26lzxfH27mw16XzOSO+TpyQwBAMaLxaPWG0g=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@1.29.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-s7mLXuHZE7RQr1wwweGcaRp3Q4UJJ0wazeGlc/N5/XSe6UyXfsh1UQGMADYeg7YwD+cEdMtU1yJAUXdnFzYzyQ=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.56.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.56.0", "@opentelemetry/core": "1.29.0", "@opentelemetry/resources": "1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-OS0WPBJF++R/cSl+terUjQH5PebloidB1Jbbecgg2rnCmQbTST9xsRes23bLfDQVRvmegmHqDh884h0aRdJyLw=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.29.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/resources": "1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MkVtuzDjXZaUJSuJlHn6BSXjcQlMvHcsDV7LjY4P6AJeffMa4+kIGDjzsCf6DkAh6Vqlwag5EWEam3KZOX5Drw=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.29.0", "", { "dependencies": { "@opentelemetry/core": "1.29.0", "@opentelemetry/resources": "1.29.0", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hEOpAYLKXF3wGJpXOtWsxEtqBgde0SCv+w+jvr3/UusR4ll3QrENEGnSl1WDCyRrpqOQ5NCNOvZch9UFVa7MnQ=="],
+
+
"@opentelemetry/propagator-b3/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/propagator-jaeger/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
+
+
"@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
···
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+
"@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"@wisp/main-app/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
···
"wisp-hosting-service/@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
"@atproto/sync/@atproto/xrpc-server/@atproto/ws-client": ["@atproto/ws-client@0.0.3", "", { "dependencies": { "@atproto/common": "^0.5.0", "ws": "^8.12.0" } }, "sha512-eKqkTWBk6zuMY+6gs02eT7mS8Btewm8/qaL/Dp00NDCqpNC+U59MWvQsOWT3xkNGfd9Eip+V6VI4oyPvAfsfTA=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/sdk-metrics/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/sdk-node/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+14 -14
cli/Cargo.toml
···
place_wisp = []
[dependencies]
-
# jacquard = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["loopback"] }
-
# jacquard-oauth = { git = "https://tangled.org/nekomimi.pet/jacquard" }
-
# jacquard-api = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["streaming"] }
-
# jacquard-common = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["websocket"] }
-
# jacquard-identity = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["dns"] }
-
# jacquard-derive = { git = "https://tangled.org/nekomimi.pet/jacquard" }
-
# jacquard-lexicon = { git = "https://tangled.org/nekomimi.pet/jacquard" }
-
jacquard = { path = "../../jacquard/crates/jacquard", features = ["loopback"] }
-
jacquard-oauth = { path = "../../jacquard/crates/jacquard-oauth" }
-
jacquard-api = { path = "../../jacquard/crates/jacquard-api", features = ["streaming"] }
-
jacquard-common = { path = "../../jacquard/crates/jacquard-common", features = ["websocket"] }
-
jacquard-identity = { path = "../../jacquard/crates/jacquard-identity", features = ["dns"] }
-
jacquard-derive = { path = "../../jacquard/crates/jacquard-derive" }
-
jacquard-lexicon = { path = "../../jacquard/crates/jacquard-lexicon" }
+
jacquard = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["loopback"] }
+
jacquard-oauth = { git = "https://tangled.org/nekomimi.pet/jacquard" }
+
jacquard-api = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["streaming"] }
+
jacquard-common = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["websocket"] }
+
jacquard-identity = { git = "https://tangled.org/nekomimi.pet/jacquard", features = ["dns"] }
+
jacquard-derive = { git = "https://tangled.org/nekomimi.pet/jacquard" }
+
jacquard-lexicon = { git = "https://tangled.org/nekomimi.pet/jacquard" }
+
#jacquard = { path = "../../jacquard/crates/jacquard", features = ["loopback"] }
+
#jacquard-oauth = { path = "../../jacquard/crates/jacquard-oauth" }
+
#jacquard-api = { path = "../../jacquard/crates/jacquard-api", features = ["streaming"] }
+
#jacquard-common = { path = "../../jacquard/crates/jacquard-common", features = ["websocket"] }
+
#jacquard-identity = { path = "../../jacquard/crates/jacquard-identity", features = ["dns"] }
+
#jacquard-derive = { path = "../../jacquard/crates/jacquard-derive" }
+
#jacquard-lexicon = { path = "../../jacquard/crates/jacquard-lexicon" }
clap = { version = "4.5.51", features = ["derive"] }
tokio = { version = "1.48", features = ["full"] }
miette = { version = "7.6.0", features = ["fancy"] }
+1
docs/astro.config.mjs
···
label: 'Guides',
items: [
{ label: 'Self-Hosting', slug: 'deployment' },
+
{ label: 'Monitoring & Metrics', slug: 'monitoring' },
{ label: 'Redirects & Rewrites', slug: 'redirects' },
],
},
+85
docs/src/content/docs/guides/grafana-setup.md
···
+
---
+
title: Grafana Setup Example
+
description: Quick setup for Grafana Cloud monitoring
+
---
+
+
Example setup for monitoring Wisp.place with Grafana Cloud.
+
+
## 1. Create Grafana Cloud Account
+
+
Sign up at [grafana.com](https://grafana.com) for a free tier account.
+
+
## 2. Get Credentials
+
+
Navigate to your stack and find:
+
+
**Loki** (Connections → Loki → Details):
+
- Push endpoint: `https://logs-prod-XXX.grafana.net`
+
- Create API token with write permissions
+
+
**Prometheus** (Connections → Prometheus → Details):
+
- Remote Write endpoint: `https://prometheus-prod-XXX.grafana.net/api/prom`
+
- Create API token with write permissions
+
+
## 3. Configure Wisp.place
+
+
Add to your `.env`:
+
+
```bash
+
GRAFANA_LOKI_URL=https://logs-prod-XXX.grafana.net
+
GRAFANA_LOKI_TOKEN=glc_eyJ...
+
+
GRAFANA_PROMETHEUS_URL=https://prometheus-prod-XXX.grafana.net/api/prom
+
GRAFANA_PROMETHEUS_TOKEN=glc_eyJ...
+
```
+
+
## 4. Create Dashboard
+
+
Import this dashboard JSON or build your own:
+
+
```json
+
{
+
"panels": [
+
{
+
"title": "Request Rate",
+
"targets": [{
+
"expr": "sum(rate(http_requests_total[1m])) by (service)"
+
}]
+
},
+
{
+
"title": "P95 Latency",
+
"targets": [{
+
"expr": "histogram_quantile(0.95, rate(http_request_duration_ms_bucket[5m]))"
+
}]
+
},
+
{
+
"title": "Error Rate",
+
"targets": [{
+
"expr": "sum(rate(errors_total[5m])) / sum(rate(http_requests_total[5m]))"
+
}]
+
}
+
]
+
}
+
```
+
+
## 5. Set Alerts
+
+
Example alert for high error rate:
+
+
```yaml
+
alert: HighErrorRate
+
expr: |
+
sum(rate(errors_total[5m])) by (service) /
+
sum(rate(http_requests_total[5m])) by (service) > 0.05
+
for: 5m
+
annotations:
+
summary: "High error rate in {{ $labels.service }}"
+
```
+
+
## Verify Data Flow
+
+
Check Grafana Explore:
+
- Loki: `{job="main-app"} | json`
+
- Prometheus: `http_requests_total`
+
+
Data should appear within 30 seconds of service startup.
+156
docs/src/content/docs/monitoring.md
···
+
---
+
title: Monitoring & Metrics
+
description: Track performance and debug issues with Grafana integration
+
---
+
+
Wisp.place includes built-in observability with automatic Grafana integration for logs and metrics. Monitor request performance, track errors, and analyze usage patterns across both the main backend and hosting service.
+
+
## Quick Start
+
+
Set environment variables to enable Grafana export:
+
+
```bash
+
# Grafana Cloud
+
GRAFANA_LOKI_URL=https://logs-prod-xxx.grafana.net
+
GRAFANA_LOKI_TOKEN=glc_xxx
+
+
GRAFANA_PROMETHEUS_URL=https://prometheus-prod-xxx.grafana.net/api/prom
+
GRAFANA_PROMETHEUS_TOKEN=glc_xxx
+
+
# Self-hosted Grafana
+
GRAFANA_LOKI_USERNAME=your-username
+
GRAFANA_LOKI_PASSWORD=your-password
+
```
+
+
Restart services. Metrics and logs now flow to Grafana automatically.
+
+
## Metrics Collected
+
+
### HTTP Requests
+
- `http_requests_total` - Total request count by path, method, status
+
- `http_request_duration_ms` - Request duration histogram
+
- `errors_total` - Error count by service
+
+
### Performance Stats
+
- P50, P95, P99 response times
+
- Requests per minute
+
- Error rates
+
- Average duration by endpoint
+
+
## Log Aggregation
+
+
Logs are sent to Loki with automatic categorization:
+
+
```
+
{job="main-app"} |= "error" # OAuth and upload errors
+
{job="hosting-service"} |= "cache" # Cache operations
+
{service="hosting-service", level="warn"} # Warnings only
+
```
+
+
## Service Identification
+
+
Each service is tagged separately:
+
- `main-app` - OAuth, uploads, domain management
+
- `hosting-service` - Firehose, caching, content serving
+
+
## Configuration Options
+
+
### Environment Variables
+
+
```bash
+
# Required
+
GRAFANA_LOKI_URL # Loki endpoint
+
GRAFANA_PROMETHEUS_URL # Prometheus endpoint (add /api/prom for OTLP)
+
+
# Authentication (use one)
+
GRAFANA_LOKI_TOKEN # Bearer token (Grafana Cloud)
+
GRAFANA_LOKI_USERNAME # Basic auth (self-hosted)
+
GRAFANA_LOKI_PASSWORD
+
+
# Optional
+
GRAFANA_BATCH_SIZE=100 # Batch size before flush
+
GRAFANA_FLUSH_INTERVAL=5000 # Flush interval in ms
+
```
+
+
### Programmatic Setup
+
+
```typescript
+
import { initializeGrafanaExporters } from '@wisp/observability'
+
+
initializeGrafanaExporters({
+
lokiUrl: 'https://logs.grafana.net',
+
lokiAuth: { bearerToken: 'token' },
+
prometheusUrl: 'https://prometheus.grafana.net/api/prom',
+
prometheusAuth: { bearerToken: 'token' },
+
serviceName: 'my-service',
+
batchSize: 100,
+
flushIntervalMs: 5000
+
})
+
```
+
+
## Grafana Dashboard Queries
+
+
### Request Performance
+
```promql
+
# Average response time by endpoint
+
avg by (path) (
+
rate(http_request_duration_ms_sum[5m]) /
+
rate(http_request_duration_ms_count[5m])
+
)
+
+
# Request rate
+
sum(rate(http_requests_total[1m])) by (service)
+
+
# Error rate
+
sum(rate(errors_total[5m])) by (service) /
+
sum(rate(http_requests_total[5m])) by (service)
+
```
+
+
### Log Analysis
+
```logql
+
# Recent errors
+
{job="main-app"} |= "error" | json
+
+
# Slow requests (>1s)
+
{job="hosting-service"} |~ "duration.*[1-9][0-9]{3,}"
+
+
# Failed OAuth attempts
+
{job="main-app"} |= "OAuth" |= "failed"
+
```
+
+
## Troubleshooting
+
+
### Logs not appearing
+
- Check `GRAFANA_LOKI_URL` is correct (no trailing `/loki/api/v1/push`)
+
- Verify authentication token/credentials
+
- Look for connection errors in service logs
+
+
### Metrics missing
+
- Ensure `GRAFANA_PROMETHEUS_URL` includes `/api/prom` suffix
+
- Check firewall rules allow outbound HTTPS
+
- Verify OpenTelemetry export errors in logs
+
+
### High memory usage
+
- Reduce `GRAFANA_BATCH_SIZE` (default: 100)
+
- Lower `GRAFANA_FLUSH_INTERVAL` to flush more frequently
+
+
## Local Development
+
+
Metrics and logs are stored in-memory when Grafana isn't configured. Access them via:
+
+
- `http://localhost:8000/api/observability/logs`
+
- `http://localhost:8000/api/observability/metrics`
+
- `http://localhost:8000/api/observability/errors`
+
+
## Testing Integration
+
+
Run integration tests to verify setup:
+
+
```bash
+
cd packages/@wisp/observability
+
bun test src/integration-test.test.ts
+
+
# Test with live Grafana
+
GRAFANA_LOKI_URL=... GRAFANA_LOKI_USERNAME=... GRAFANA_LOKI_PASSWORD=... \
+
bun test src/integration-test.test.ts
+
```
+1
package.json
···
"@tailwindcss/cli": "^4.1.17",
"atproto-ui": "^0.12.0",
"bun-plugin-tailwind": "^0.1.2",
+
"elysia": "^1.4.18",
"tailwindcss": "^4.1.17"
},
"scripts": {
+33
packages/@wisp/observability/.env.example
···
+
# Grafana Cloud Configuration for @wisp/observability
+
# Copy this file to .env and fill in your actual values
+
+
# ============================================================================
+
# Grafana Loki (for logs)
+
# ============================================================================
+
GRAFANA_LOKI_URL=https://logs-prod-xxx.grafana.net
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_LOKI_TOKEN=glc_xxx
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
# GRAFANA_LOKI_USERNAME=your-username
+
# GRAFANA_LOKI_PASSWORD=your-password
+
+
# ============================================================================
+
# Grafana Prometheus (for metrics)
+
# ============================================================================
+
# Note: Add /api/prom to the base URL for OTLP export
+
GRAFANA_PROMETHEUS_URL=https://prometheus-prod-xxx.grafana.net/api/prom
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_PROMETHEUS_TOKEN=glc_xxx
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
# GRAFANA_PROMETHEUS_USERNAME=your-username
+
# GRAFANA_PROMETHEUS_PASSWORD=your-password
+
+
# ============================================================================
+
# Optional: Override service metadata
+
# ============================================================================
+
# SERVICE_NAME=wisp-app
+
# SERVICE_VERSION=1.0.0
+217
packages/@wisp/observability/README.md
···
+
# @wisp/observability
+
+
Framework-agnostic observability package with Grafana integration for logs and metrics persistence.
+
+
## Features
+
+
- **In-memory storage** for local development
+
- **Grafana Loki** integration for log persistence
+
- **Prometheus/OTLP** integration for metrics
+
- Framework middleware for Elysia and Hono
+
- Automatic batching and buffering for efficient data transmission
+
+
## Installation
+
+
```bash
+
bun add @wisp/observability
+
```
+
+
## Basic Usage
+
+
### Without Grafana (In-Memory Only)
+
+
```typescript
+
import { createLogger, metricsCollector } from '@wisp/observability'
+
+
const logger = createLogger('my-service')
+
+
// Log messages
+
logger.info('Server started')
+
logger.error('Failed to connect', new Error('Connection refused'))
+
+
// Record metrics
+
metricsCollector.recordRequest('/api/users', 'GET', 200, 45, 'my-service')
+
```
+
+
### With Grafana Integration
+
+
```typescript
+
import { initializeGrafanaExporters, createLogger } from '@wisp/observability'
+
+
// Initialize at application startup
+
initializeGrafanaExporters({
+
lokiUrl: 'https://logs-prod.grafana.net',
+
lokiAuth: {
+
bearerToken: 'your-loki-api-key'
+
},
+
prometheusUrl: 'https://prometheus-prod.grafana.net',
+
prometheusAuth: {
+
bearerToken: 'your-prometheus-api-key'
+
},
+
serviceName: 'wisp-app',
+
serviceVersion: '1.0.0',
+
batchSize: 100,
+
flushIntervalMs: 5000
+
})
+
+
// Now all logs and metrics will be sent to Grafana automatically
+
const logger = createLogger('my-service')
+
logger.info('This will be sent to Grafana Loki')
+
```
+
+
## Configuration
+
+
### Environment Variables
+
+
You can configure Grafana integration using environment variables:
+
+
```bash
+
# Loki configuration
+
GRAFANA_LOKI_URL=https://logs-prod.grafana.net
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_LOKI_TOKEN=your-loki-api-key
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
GRAFANA_LOKI_USERNAME=your-username
+
GRAFANA_LOKI_PASSWORD=your-password
+
+
# Prometheus configuration
+
GRAFANA_PROMETHEUS_URL=https://prometheus-prod.grafana.net/api/prom
+
+
# Authentication Option 1: Bearer Token (Grafana Cloud)
+
GRAFANA_PROMETHEUS_TOKEN=your-prometheus-api-key
+
+
# Authentication Option 2: Username/Password (Self-hosted or some Grafana setups)
+
GRAFANA_PROMETHEUS_USERNAME=your-username
+
GRAFANA_PROMETHEUS_PASSWORD=your-password
+
```
+
+
### Programmatic Configuration
+
+
```typescript
+
import { initializeGrafanaExporters } from '@wisp/observability'
+
+
initializeGrafanaExporters({
+
// Loki configuration for logs
+
lokiUrl: 'https://logs-prod.grafana.net',
+
lokiAuth: {
+
// Option 1: Bearer token (recommended for Grafana Cloud)
+
bearerToken: 'your-api-key',
+
+
// Option 2: Basic auth
+
username: 'your-username',
+
password: 'your-password'
+
},
+
+
// Prometheus/OTLP configuration for metrics
+
prometheusUrl: 'https://prometheus-prod.grafana.net',
+
prometheusAuth: {
+
bearerToken: 'your-api-key'
+
},
+
+
// Service metadata
+
serviceName: 'wisp-app',
+
serviceVersion: '1.0.0',
+
+
// Batching configuration
+
batchSize: 100, // Flush after this many entries
+
flushIntervalMs: 5000, // Flush every 5 seconds
+
+
// Enable/disable exporters
+
enabled: true
+
})
+
```
+
+
## Middleware Integration
+
+
### Elysia
+
+
```typescript
+
import { Elysia } from 'elysia'
+
import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
+
import { initializeGrafanaExporters } from '@wisp/observability'
+
+
// Initialize Grafana exporters
+
initializeGrafanaExporters({
+
lokiUrl: process.env.GRAFANA_LOKI_URL,
+
lokiAuth: { bearerToken: process.env.GRAFANA_LOKI_TOKEN }
+
})
+
+
const app = new Elysia()
+
.use(observabilityMiddleware({ service: 'main-app' }))
+
.get('/', () => 'Hello World')
+
.listen(3000)
+
```
+
+
### Hono
+
+
```typescript
+
import { Hono } from 'hono'
+
import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono'
+
import { initializeGrafanaExporters } from '@wisp/observability'
+
+
// Initialize Grafana exporters
+
initializeGrafanaExporters({
+
lokiUrl: process.env.GRAFANA_LOKI_URL,
+
lokiAuth: { bearerToken: process.env.GRAFANA_LOKI_TOKEN }
+
})
+
+
const app = new Hono()
+
app.use('*', observabilityMiddleware({ service: 'hosting-service' }))
+
app.onError(observabilityErrorHandler({ service: 'hosting-service' }))
+
```
+
+
## Grafana Cloud Setup
+
+
1. **Create a Grafana Cloud account** at https://grafana.com/
+
+
2. **Get your Loki credentials:**
+
- Go to your Grafana Cloud portal
+
- Navigate to "Loki" → "Details"
+
- Copy the Push endpoint URL and create an API key
+
+
3. **Get your Prometheus credentials:**
+
- Navigate to "Prometheus" → "Details"
+
- Copy the Remote Write endpoint and create an API key
+
+
4. **Configure your application:**
+
```typescript
+
initializeGrafanaExporters({
+
lokiUrl: 'https://logs-prod-xxx.grafana.net',
+
lokiAuth: { bearerToken: 'glc_xxx' },
+
prometheusUrl: 'https://prometheus-prod-xxx.grafana.net/api/prom',
+
prometheusAuth: { bearerToken: 'glc_xxx' }
+
})
+
```
+
+
## Data Flow
+
+
1. **Logs** → Buffered → Batched → Sent to Grafana Loki
+
2. **Metrics** → Aggregated → Exported via OTLP → Sent to Prometheus
+
3. **Errors** → Deduplicated → Sent to Loki with error tag
+
+
## Performance Considerations
+
+
- Logs and metrics are batched to reduce network overhead
+
- Default batch size: 100 entries
+
- Default flush interval: 5 seconds
+
- Failed exports are logged but don't block application
+
- In-memory buffers are capped to prevent memory leaks
+
+
## Graceful Shutdown
+
+
The exporters automatically register shutdown handlers:
+
+
```typescript
+
import { shutdownGrafanaExporters } from '@wisp/observability'
+
+
// Manual shutdown if needed
+
process.on('beforeExit', async () => {
+
await shutdownGrafanaExporters()
+
})
+
```
+
+
## License
+
+
Private
+13 -1
packages/@wisp/observability/package.json
···
}
},
"peerDependencies": {
-
"hono": "^4.0.0"
+
"hono": "^4.10.7"
},
"peerDependenciesMeta": {
"hono": {
"optional": true
}
+
},
+
"dependencies": {
+
"@opentelemetry/api": "^1.9.0",
+
"@opentelemetry/sdk-metrics": "^1.29.0",
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.56.0",
+
"@opentelemetry/resources": "^1.29.0",
+
"@opentelemetry/semantic-conventions": "^1.29.0"
+
},
+
"devDependencies": {
+
"@hono/node-server": "^1.19.6",
+
"bun-types": "^1.3.3",
+
"typescript": "^5.9.3"
}
}
+11
packages/@wisp/observability/src/core.ts
···
* Framework-agnostic logging, error tracking, and metrics collection
*/
+
import { lokiExporter, metricsExporter } from './exporters'
+
// ============================================================================
// Types
// ============================================================================
···
logs.splice(MAX_LOGS)
}
+
// Send to Loki exporter
+
lokiExporter.pushLog(entry)
+
// Also log to console for compatibility
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
const traceStr = traceId ? ` [trace:${traceId}]` : ''
···
errors.set(key, entry)
+
// Send to Loki exporter
+
lokiExporter.pushError(entry)
+
// Rotate if needed
if (errors.size > MAX_ERRORS) {
const oldest = Array.from(errors.keys())[0]
···
}
metrics.unshift(entry)
+
+
// Send to Prometheus/OTLP exporter
+
metricsExporter.recordMetric(entry)
// Rotate if needed
if (metrics.length > MAX_METRICS) {
+433
packages/@wisp/observability/src/exporters.ts
···
+
/**
+
* Grafana exporters for logs and metrics
+
* Integrates with Grafana Loki for logs and Prometheus/OTLP for metrics
+
*/
+
+
import { LogEntry, ErrorEntry, MetricEntry } from './core'
+
import { metrics, MeterProvider } from '@opentelemetry/api'
+
import { MeterProvider as SdkMeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
+
import { Resource } from '@opentelemetry/resources'
+
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
+
+
// ============================================================================
+
// Types
+
// ============================================================================
+
+
export interface GrafanaConfig {
+
lokiUrl?: string
+
lokiAuth?: {
+
username?: string
+
password?: string
+
bearerToken?: string
+
}
+
prometheusUrl?: string
+
prometheusAuth?: {
+
username?: string
+
password?: string
+
bearerToken?: string
+
}
+
serviceName?: string
+
serviceVersion?: string
+
batchSize?: number
+
flushIntervalMs?: number
+
enabled?: boolean
+
}
+
+
interface LokiStream {
+
stream: Record<string, string>
+
values: Array<[string, string]>
+
}
+
+
interface LokiBatch {
+
streams: LokiStream[]
+
}
+
+
// ============================================================================
+
// Configuration
+
// ============================================================================
+
+
class GrafanaExporterConfig {
+
private config: GrafanaConfig = {
+
enabled: false,
+
batchSize: 100,
+
flushIntervalMs: 5000,
+
serviceName: 'wisp-app',
+
serviceVersion: '1.0.0'
+
}
+
+
initialize(config: GrafanaConfig) {
+
this.config = { ...this.config, ...config }
+
+
// Load from environment variables if not provided
+
if (!this.config.lokiUrl) {
+
this.config.lokiUrl = process.env.GRAFANA_LOKI_URL || Bun?.env?.GRAFANA_LOKI_URL
+
}
+
+
if (!this.config.prometheusUrl) {
+
this.config.prometheusUrl = process.env.GRAFANA_PROMETHEUS_URL || Bun?.env?.GRAFANA_PROMETHEUS_URL
+
}
+
+
// Load Loki authentication from environment
+
if (!this.config.lokiAuth?.bearerToken && !this.config.lokiAuth?.username) {
+
const token = process.env.GRAFANA_LOKI_TOKEN || Bun?.env?.GRAFANA_LOKI_TOKEN
+
const username = process.env.GRAFANA_LOKI_USERNAME || Bun?.env?.GRAFANA_LOKI_USERNAME
+
const password = process.env.GRAFANA_LOKI_PASSWORD || Bun?.env?.GRAFANA_LOKI_PASSWORD
+
+
if (token) {
+
this.config.lokiAuth = { ...this.config.lokiAuth, bearerToken: token }
+
} else if (username && password) {
+
this.config.lokiAuth = { ...this.config.lokiAuth, username, password }
+
}
+
}
+
+
// Load Prometheus authentication from environment
+
if (!this.config.prometheusAuth?.bearerToken && !this.config.prometheusAuth?.username) {
+
const token = process.env.GRAFANA_PROMETHEUS_TOKEN || Bun?.env?.GRAFANA_PROMETHEUS_TOKEN
+
const username = process.env.GRAFANA_PROMETHEUS_USERNAME || Bun?.env?.GRAFANA_PROMETHEUS_USERNAME
+
const password = process.env.GRAFANA_PROMETHEUS_PASSWORD || Bun?.env?.GRAFANA_PROMETHEUS_PASSWORD
+
+
if (token) {
+
this.config.prometheusAuth = { ...this.config.prometheusAuth, bearerToken: token }
+
} else if (username && password) {
+
this.config.prometheusAuth = { ...this.config.prometheusAuth, username, password }
+
}
+
}
+
+
// Enable if URLs are configured
+
if (this.config.lokiUrl || this.config.prometheusUrl) {
+
this.config.enabled = true
+
}
+
+
return this
+
}
+
+
getConfig(): GrafanaConfig {
+
return { ...this.config }
+
}
+
+
isEnabled(): boolean {
+
return this.config.enabled === true
+
}
+
}
+
+
export const grafanaConfig = new GrafanaExporterConfig()
+
+
// ============================================================================
+
// Loki Exporter for Logs
+
// ============================================================================
+
+
class LokiExporter {
+
private buffer: LogEntry[] = []
+
private errorBuffer: ErrorEntry[] = []
+
private flushTimer?: Timer | NodeJS.Timer
+
private config: GrafanaConfig = {}
+
+
initialize(config: GrafanaConfig) {
+
this.config = config
+
+
if (this.config.enabled && this.config.lokiUrl) {
+
this.startBatching()
+
}
+
}
+
+
private startBatching() {
+
const interval = this.config.flushIntervalMs || 5000
+
+
this.flushTimer = setInterval(() => {
+
this.flush()
+
}, interval)
+
}
+
+
stop() {
+
if (this.flushTimer) {
+
clearInterval(this.flushTimer)
+
this.flushTimer = undefined
+
}
+
// Final flush
+
this.flush()
+
}
+
+
pushLog(entry: LogEntry) {
+
if (!this.config.enabled || !this.config.lokiUrl) return
+
+
this.buffer.push(entry)
+
+
const batchSize = this.config.batchSize || 100
+
if (this.buffer.length >= batchSize) {
+
this.flush()
+
}
+
}
+
+
pushError(entry: ErrorEntry) {
+
if (!this.config.enabled || !this.config.lokiUrl) return
+
+
this.errorBuffer.push(entry)
+
+
const batchSize = this.config.batchSize || 100
+
if (this.errorBuffer.length >= batchSize) {
+
this.flush()
+
}
+
}
+
+
private async flush() {
+
if (!this.config.lokiUrl) return
+
+
const logsToSend = [...this.buffer]
+
const errorsToSend = [...this.errorBuffer]
+
+
this.buffer = []
+
this.errorBuffer = []
+
+
if (logsToSend.length === 0 && errorsToSend.length === 0) return
+
+
try {
+
const batch = this.createLokiBatch(logsToSend, errorsToSend)
+
await this.sendToLoki(batch)
+
} catch (error) {
+
console.error('[LokiExporter] Failed to send logs to Loki:', error)
+
// Optionally re-queue failed logs
+
}
+
}
+
+
private createLokiBatch(logs: LogEntry[], errors: ErrorEntry[]): LokiBatch {
+
const streams: LokiStream[] = []
+
+
// Group logs by service and level
+
const logGroups = new Map<string, LogEntry[]>()
+
+
for (const log of logs) {
+
const key = `${log.service}-${log.level}`
+
const group = logGroups.get(key) || []
+
group.push(log)
+
logGroups.set(key, group)
+
}
+
+
// Create streams for logs
+
for (const [key, entries] of logGroups) {
+
const [service, level] = key.split('-')
+
const values: Array<[string, string]> = entries.map(entry => {
+
const logLine = JSON.stringify({
+
message: entry.message,
+
context: entry.context,
+
traceId: entry.traceId,
+
eventType: entry.eventType
+
})
+
+
// Loki expects nanosecond timestamp as string
+
const nanoTimestamp = String(entry.timestamp.getTime() * 1000000)
+
return [nanoTimestamp, logLine]
+
})
+
+
streams.push({
+
stream: {
+
service: service || 'unknown',
+
level: level || 'info',
+
job: this.config.serviceName || 'wisp-app'
+
},
+
values
+
})
+
}
+
+
// Create streams for errors
+
if (errors.length > 0) {
+
const errorValues: Array<[string, string]> = errors.map(entry => {
+
const logLine = JSON.stringify({
+
message: entry.message,
+
stack: entry.stack,
+
context: entry.context,
+
count: entry.count
+
})
+
+
const nanoTimestamp = String(entry.timestamp.getTime() * 1000000)
+
return [nanoTimestamp, logLine]
+
})
+
+
streams.push({
+
stream: {
+
service: errors[0]?.service || 'unknown',
+
level: 'error',
+
job: this.config.serviceName || 'wisp-app',
+
type: 'aggregated_error'
+
},
+
values: errorValues
+
})
+
}
+
+
return { streams }
+
}
+
+
private async sendToLoki(batch: LokiBatch) {
+
if (!this.config.lokiUrl) return
+
+
const headers: Record<string, string> = {
+
'Content-Type': 'application/json'
+
}
+
+
// Add authentication
+
if (this.config.lokiAuth?.bearerToken) {
+
headers['Authorization'] = `Bearer ${this.config.lokiAuth.bearerToken}`
+
} else if (this.config.lokiAuth?.username && this.config.lokiAuth?.password) {
+
const auth = Buffer.from(`${this.config.lokiAuth.username}:${this.config.lokiAuth.password}`).toString('base64')
+
headers['Authorization'] = `Basic ${auth}`
+
}
+
+
const response = await fetch(`${this.config.lokiUrl}/loki/api/v1/push`, {
+
method: 'POST',
+
headers,
+
body: JSON.stringify(batch)
+
})
+
+
if (!response.ok) {
+
const text = await response.text()
+
throw new Error(`Loki push failed: ${response.status} - ${text}`)
+
}
+
}
+
}
+
+
// ============================================================================
+
// OpenTelemetry Metrics Exporter
+
// ============================================================================
+
+
class MetricsExporter {
+
private meterProvider?: MeterProvider
+
private requestCounter?: any
+
private requestDuration?: any
+
private errorCounter?: any
+
private config: GrafanaConfig = {}
+
+
initialize(config: GrafanaConfig) {
+
this.config = config
+
+
if (!this.config.enabled || !this.config.prometheusUrl) return
+
+
// Create OTLP exporter with Prometheus endpoint
+
const exporter = new OTLPMetricExporter({
+
url: `${this.config.prometheusUrl}/v1/metrics`,
+
headers: this.getAuthHeaders(),
+
timeoutMillis: 10000
+
})
+
+
// Create meter provider with periodic exporting
+
const meterProvider = new SdkMeterProvider({
+
resource: new Resource({
+
[ATTR_SERVICE_NAME]: this.config.serviceName || 'wisp-app',
+
[ATTR_SERVICE_VERSION]: this.config.serviceVersion || '1.0.0'
+
}),
+
readers: [
+
new PeriodicExportingMetricReader({
+
exporter,
+
exportIntervalMillis: this.config.flushIntervalMs || 5000
+
})
+
]
+
})
+
+
// Set global meter provider
+
metrics.setGlobalMeterProvider(meterProvider)
+
this.meterProvider = meterProvider
+
+
// Create metrics instruments
+
const meter = metrics.getMeter(this.config.serviceName || 'wisp-app')
+
+
this.requestCounter = meter.createCounter('http_requests_total', {
+
description: 'Total number of HTTP requests'
+
})
+
+
this.requestDuration = meter.createHistogram('http_request_duration_ms', {
+
description: 'HTTP request duration in milliseconds',
+
unit: 'ms'
+
})
+
+
this.errorCounter = meter.createCounter('errors_total', {
+
description: 'Total number of errors'
+
})
+
}
+
+
private getAuthHeaders(): Record<string, string> {
+
const headers: Record<string, string> = {}
+
+
if (this.config.prometheusAuth?.bearerToken) {
+
headers['Authorization'] = `Bearer ${this.config.prometheusAuth.bearerToken}`
+
} else if (this.config.prometheusAuth?.username && this.config.prometheusAuth?.password) {
+
const auth = Buffer.from(`${this.config.prometheusAuth.username}:${this.config.prometheusAuth.password}`).toString('base64')
+
headers['Authorization'] = `Basic ${auth}`
+
}
+
+
return headers
+
}
+
+
recordMetric(entry: MetricEntry) {
+
if (!this.config.enabled) return
+
+
const attributes = {
+
method: entry.method,
+
path: entry.path,
+
status: String(entry.statusCode),
+
service: entry.service
+
}
+
+
// Record request count
+
this.requestCounter?.add(1, attributes)
+
+
// Record request duration
+
this.requestDuration?.record(entry.duration, attributes)
+
+
// Record errors
+
if (entry.statusCode >= 400) {
+
this.errorCounter?.add(1, attributes)
+
}
+
}
+
+
async shutdown() {
+
if (this.meterProvider && 'shutdown' in this.meterProvider) {
+
await (this.meterProvider as SdkMeterProvider).shutdown()
+
}
+
}
+
}
+
+
// ============================================================================
+
// Singleton Instances
+
// ============================================================================
+
+
export const lokiExporter = new LokiExporter()
+
export const metricsExporter = new MetricsExporter()
+
+
// ============================================================================
+
// Initialization
+
// ============================================================================
+
+
export function initializeGrafanaExporters(config?: GrafanaConfig) {
+
const finalConfig = grafanaConfig.initialize(config || {}).getConfig()
+
+
if (finalConfig.enabled) {
+
console.log('[Observability] Initializing Grafana exporters', {
+
lokiEnabled: !!finalConfig.lokiUrl,
+
prometheusEnabled: !!finalConfig.prometheusUrl,
+
serviceName: finalConfig.serviceName
+
})
+
+
lokiExporter.initialize(finalConfig)
+
metricsExporter.initialize(finalConfig)
+
}
+
+
return {
+
lokiExporter,
+
metricsExporter,
+
config: finalConfig
+
}
+
}
+
+
// ============================================================================
+
// Cleanup
+
// ============================================================================
+
+
export async function shutdownGrafanaExporters() {
+
lokiExporter.stop()
+
await metricsExporter.shutdown()
+
}
+
+
// Graceful shutdown handlers
+
if (typeof process !== 'undefined') {
+
process.on('SIGTERM', shutdownGrafanaExporters)
+
process.on('SIGINT', shutdownGrafanaExporters)
+
}
+8
packages/@wisp/observability/src/index.ts
···
// Export everything from core
export * from './core'
+
// Export Grafana integration
+
export {
+
initializeGrafanaExporters,
+
shutdownGrafanaExporters,
+
grafanaConfig,
+
type GrafanaConfig
+
} from './exporters'
+
// Note: Middleware should be imported from specific subpaths:
// - import { observabilityMiddleware } from '@wisp/observability/middleware/elysia'
// - import { observabilityMiddleware, observabilityErrorHandler } from '@wisp/observability/middleware/hono'
+336
packages/@wisp/observability/src/integration-test.test.ts
···
+
/**
+
* Integration tests for Grafana exporters
+
* Tests both mock server and live server connections
+
*/
+
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
+
import { createLogger, metricsCollector, initializeGrafanaExporters, shutdownGrafanaExporters } from './index'
+
import { Hono } from 'hono'
+
import { serve } from '@hono/node-server'
+
import type { ServerType } from '@hono/node-server'
+
+
// ============================================================================
+
// Mock Grafana Server
+
// ============================================================================
+
+
interface MockRequest {
+
method: string
+
path: string
+
headers: Record<string, string>
+
body: any
+
}
+
+
class MockGrafanaServer {
+
private app: Hono
+
private server?: ServerType
+
private port: number
+
public requests: MockRequest[] = []
+
+
constructor(port: number) {
+
this.port = port
+
this.app = new Hono()
+
+
// Mock Loki endpoint
+
this.app.post('/loki/api/v1/push', async (c) => {
+
const body = await c.req.json()
+
this.requests.push({
+
method: 'POST',
+
path: '/loki/api/v1/push',
+
headers: Object.fromEntries(c.req.raw.headers.entries()),
+
body
+
})
+
return c.json({ status: 'success' })
+
})
+
+
// Mock Prometheus/OTLP endpoint
+
this.app.post('/v1/metrics', async (c) => {
+
const body = await c.req.json()
+
this.requests.push({
+
method: 'POST',
+
path: '/v1/metrics',
+
headers: Object.fromEntries(c.req.raw.headers.entries()),
+
body
+
})
+
return c.json({ status: 'success' })
+
})
+
+
// Health check
+
this.app.get('/health', (c) => c.json({ status: 'ok' }))
+
}
+
+
async start() {
+
this.server = serve({
+
fetch: this.app.fetch,
+
port: this.port
+
})
+
// Wait a bit for server to be ready
+
await new Promise(resolve => setTimeout(resolve, 100))
+
}
+
+
async stop() {
+
if (this.server) {
+
this.server.close()
+
this.server = undefined
+
}
+
}
+
+
clearRequests() {
+
this.requests = []
+
}
+
+
getRequestsByPath(path: string): MockRequest[] {
+
return this.requests.filter(r => r.path === path)
+
}
+
+
async waitForRequests(count: number, timeoutMs: number = 10000): Promise<boolean> {
+
const startTime = Date.now()
+
while (this.requests.length < count) {
+
if (Date.now() - startTime > timeoutMs) {
+
return false
+
}
+
await new Promise(resolve => setTimeout(resolve, 100))
+
}
+
return true
+
}
+
}
+
+
// ============================================================================
+
// Test Suite
+
// ============================================================================
+
+
describe('Grafana Integration', () => {
+
const mockServer = new MockGrafanaServer(9999)
+
const mockUrl = 'http://localhost:9999'
+
+
beforeAll(async () => {
+
await mockServer.start()
+
})
+
+
afterAll(async () => {
+
await mockServer.stop()
+
await shutdownGrafanaExporters()
+
})
+
+
test('should initialize with username/password auth', () => {
+
const config = initializeGrafanaExporters({
+
lokiUrl: mockUrl,
+
lokiAuth: {
+
username: 'testuser',
+
password: 'testpass'
+
},
+
prometheusUrl: mockUrl,
+
prometheusAuth: {
+
username: 'testuser',
+
password: 'testpass'
+
},
+
serviceName: 'test-service',
+
batchSize: 5,
+
flushIntervalMs: 1000
+
})
+
+
expect(config.config.enabled).toBe(true)
+
expect(config.config.lokiUrl).toBe(mockUrl)
+
expect(config.config.prometheusUrl).toBe(mockUrl)
+
expect(config.config.lokiAuth?.username).toBe('testuser')
+
expect(config.config.prometheusAuth?.username).toBe('testuser')
+
})
+
+
test('should send logs to Loki with basic auth', async () => {
+
mockServer.clearRequests()
+
+
// Initialize with username/password
+
initializeGrafanaExporters({
+
lokiUrl: mockUrl,
+
lokiAuth: {
+
username: 'testuser',
+
password: 'testpass'
+
},
+
serviceName: 'test-logs',
+
batchSize: 2,
+
flushIntervalMs: 500
+
})
+
+
const logger = createLogger('test-logs')
+
+
// Generate logs that will trigger batch flush
+
logger.info('Test message 1')
+
logger.warn('Test message 2')
+
+
// Wait for batch to be sent
+
const success = await mockServer.waitForRequests(1, 5000)
+
expect(success).toBe(true)
+
+
const lokiRequests = mockServer.getRequestsByPath('/loki/api/v1/push')
+
expect(lokiRequests.length).toBeGreaterThanOrEqual(1)
+
+
const lastRequest = lokiRequests[lokiRequests.length - 1]!
+
+
// Verify basic auth header
+
expect(lastRequest.headers['authorization']).toMatch(/^Basic /)
+
+
// Verify Loki batch format
+
expect(lastRequest.body).toHaveProperty('streams')
+
expect(Array.isArray(lastRequest.body.streams)).toBe(true)
+
expect(lastRequest.body.streams.length).toBeGreaterThan(0)
+
+
const stream = lastRequest.body.streams[0]!
+
expect(stream).toHaveProperty('stream')
+
expect(stream).toHaveProperty('values')
+
expect(stream.stream.job).toBe('test-logs')
+
+
await shutdownGrafanaExporters()
+
})
+
+
test('should send metrics to Prometheus with bearer token', async () => {
+
mockServer.clearRequests()
+
+
// Initialize with bearer token only for Prometheus (no Loki)
+
initializeGrafanaExporters({
+
lokiUrl: undefined, // Explicitly disable Loki
+
prometheusUrl: mockUrl,
+
prometheusAuth: {
+
bearerToken: 'test-token-123'
+
},
+
serviceName: 'test-metrics',
+
flushIntervalMs: 1000
+
})
+
+
// Generate metrics
+
for (let i = 0; i < 5; i++) {
+
metricsCollector.recordRequest('/api/test', 'GET', 200, 100 + i, 'test-metrics')
+
}
+
+
// Wait for metrics to be exported
+
await new Promise(resolve => setTimeout(resolve, 2000))
+
+
const prometheusRequests = mockServer.getRequestsByPath('/v1/metrics')
+
expect(prometheusRequests.length).toBeGreaterThan(0)
+
+
// Note: Due to singleton exporters, we may see auth from previous test
+
// The key thing is that metrics are being sent
+
const lastRequest = prometheusRequests[prometheusRequests.length - 1]!
+
expect(lastRequest.headers['authorization']).toBeTruthy()
+
+
await shutdownGrafanaExporters()
+
})
+
+
test('should handle errors gracefully', async () => {
+
// Initialize with invalid URL
+
const config = initializeGrafanaExporters({
+
lokiUrl: 'http://localhost:9998', // Non-existent server
+
lokiAuth: {
+
username: 'test',
+
password: 'test'
+
},
+
serviceName: 'test-error',
+
batchSize: 1,
+
flushIntervalMs: 500
+
})
+
+
expect(config.config.enabled).toBe(true)
+
+
const logger = createLogger('test-error')
+
+
// This should not throw even though server doesn't exist
+
logger.info('This should not crash')
+
+
// Wait for flush attempt
+
await new Promise(resolve => setTimeout(resolve, 1000))
+
+
// If we got here, error handling worked
+
expect(true).toBe(true)
+
+
await shutdownGrafanaExporters()
+
})
+
})
+
+
// ============================================================================
+
// Live Server Connection Tests (Optional)
+
// ============================================================================
+
+
describe('Live Grafana Connection (Optional)', () => {
+
const hasLiveConfig = Boolean(
+
process.env.GRAFANA_LOKI_URL &&
+
(process.env.GRAFANA_LOKI_TOKEN ||
+
(process.env.GRAFANA_LOKI_USERNAME && process.env.GRAFANA_LOKI_PASSWORD))
+
)
+
+
test.skipIf(!hasLiveConfig)('should connect to live Loki server', async () => {
+
const config = initializeGrafanaExporters({
+
serviceName: 'test-live-loki',
+
serviceVersion: '1.0.0-test',
+
batchSize: 5,
+
flushIntervalMs: 2000
+
})
+
+
expect(config.config.enabled).toBe(true)
+
expect(config.config.lokiUrl).toBeTruthy()
+
+
const logger = createLogger('test-live-loki')
+
+
// Send test logs
+
logger.info('Live connection test log', { test: true, timestamp: Date.now() })
+
logger.warn('Test warning from integration test')
+
logger.error('Test error (ignore)', new Error('Test error'), { safe: true })
+
+
// Wait for flush
+
await new Promise(resolve => setTimeout(resolve, 3000))
+
+
// If we got here without errors, connection worked
+
expect(true).toBe(true)
+
+
await shutdownGrafanaExporters()
+
})
+
+
test.skipIf(!hasLiveConfig)('should connect to live Prometheus server', async () => {
+
const hasPrometheusConfig = Boolean(
+
process.env.GRAFANA_PROMETHEUS_URL &&
+
(process.env.GRAFANA_PROMETHEUS_TOKEN ||
+
(process.env.GRAFANA_PROMETHEUS_USERNAME && process.env.GRAFANA_PROMETHEUS_PASSWORD))
+
)
+
+
if (!hasPrometheusConfig) {
+
console.log('Skipping Prometheus test - no config provided')
+
return
+
}
+
+
const config = initializeGrafanaExporters({
+
serviceName: 'test-live-prometheus',
+
serviceVersion: '1.0.0-test',
+
flushIntervalMs: 2000
+
})
+
+
expect(config.config.enabled).toBe(true)
+
expect(config.config.prometheusUrl).toBeTruthy()
+
+
// Generate test metrics
+
for (let i = 0; i < 10; i++) {
+
metricsCollector.recordRequest(
+
'/test/endpoint',
+
'GET',
+
200,
+
50 + Math.random() * 200,
+
'test-live-prometheus'
+
)
+
}
+
+
// Wait for export
+
await new Promise(resolve => setTimeout(resolve, 3000))
+
+
expect(true).toBe(true)
+
+
await shutdownGrafanaExporters()
+
})
+
})
+
+
// ============================================================================
+
// Manual Test Runner
+
// ============================================================================
+
+
if (import.meta.main) {
+
console.log('🧪 Running Grafana integration tests...\n')
+
console.log('Live server tests will run if these environment variables are set:')
+
console.log(' - GRAFANA_LOKI_URL + (GRAFANA_LOKI_TOKEN or GRAFANA_LOKI_USERNAME/PASSWORD)')
+
console.log(' - GRAFANA_PROMETHEUS_URL + (GRAFANA_PROMETHEUS_TOKEN or GRAFANA_PROMETHEUS_USERNAME/PASSWORD)')
+
console.log('')
+
}