feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).activity(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).distributions(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,7 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
import { getQueryRecord, parseRange } from '../../utils/validation'
export default defineAdminHandler(async (event) =>
getAdminApi(event).overview(parseRange(getQueryRecord(event).range)),
)

View File

@ -0,0 +1,8 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi, requireSession } from '../../utils/service'
export default defineAdminHandler(async (event) => {
const session = requireSession(event)
getAdminApi(event)
return { ...session, dataSource: session.mock ? 'fixture' : 'production' }
})

View File

@ -0,0 +1,4 @@
import { defineAdminHandler } from '../../utils/handler'
import { getAdminApi } from '../../utils/service'
export default defineAdminHandler(async (event) => getAdminApi(event).system())

View File

@ -0,0 +1,26 @@
import { createRequire } from 'node:module'
import type { PlatformProxy } from 'wrangler'
import type { DashboardBindings } from '../utils/bindings'
let proxy: PlatformProxy<DashboardBindings> | null = null
const require = createRequire(import.meta.url)
export default defineNitroPlugin(async (nitroApp) => {
const { getPlatformProxy } = require('wrangler') as typeof import('wrangler')
proxy = await getPlatformProxy<DashboardBindings>({
configPath: 'wrangler.toml',
persist: false,
remoteBindings: true,
})
nitroApp.hooks.hook('request', (event) => {
event.context.cloudflare = { env: proxy?.env ?? {} }
})
nitroApp.hooks.hook('close', async () => {
await proxy?.dispose()
proxy = null
})
})

View File

@ -0,0 +1,36 @@
import { getHeader } from 'h3'
import { toAdminHttpError } from '../utils/admin-http-error'
import {
accessSettings,
authenticateAccessToken,
mockAuthEnabled,
mockScenario,
mockSession,
} from '../utils/auth'
import { AdminApiError, unauthorized, unavailable } from '../utils/errors'
import type { AdminEventContext } from '../utils/service'
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/admin/')) return
try {
const config = useRuntimeConfig(event)
if (mockAuthEnabled(config)) {
;(event.context as AdminEventContext).adminSession = mockSession(mockScenario(config))
return
}
const settings = accessSettings(config)
if (!settings) {
if (process.env.NODE_ENV === 'production') throw unavailable()
throw unauthorized()
}
const token = getHeader(event, 'cf-access-jwt-assertion')
;(event.context as AdminEventContext).adminSession = await authenticateAccessToken(
token,
settings,
)
} catch (error) {
if (error instanceof AdminApiError) throw toAdminHttpError(error)
throw error
}
})

View File

@ -0,0 +1,21 @@
import type {
ActivityDto,
AdminRange,
DistributionsDto,
ErrorDetailDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { ErrorQuery } from './validation'
export interface TelemetryAdminApi {
overview(range: AdminRange): Promise<OverviewDto>
activity(range: AdminRange): Promise<ActivityDto>
distributions(range: AdminRange): Promise<DistributionsDto>
errors(query: ErrorQuery): Promise<ErrorsPageDto>
errorDetail(fingerprint: string): Promise<ErrorDetailDto | null>
errorSample(fingerprint: string): Promise<ErrorSampleDto | null>
system(): Promise<SystemDto>
}

View File

@ -0,0 +1,11 @@
import { createError } from 'h3'
import type { AdminApiError } from './errors'
export function toAdminHttpError(error: AdminApiError) {
return createError({
statusCode: error.statusCode,
statusMessage: error.message,
data: { error: { code: error.code, message: error.message } },
})
}

View File

@ -0,0 +1,78 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose'
import type { AdminSessionDto } from '../../shared/types/telemetry'
import { forbidden, unauthorized } from './errors'
export interface AccessSettings {
teamDomain: string
audience: string
}
type VerifyKey = JWTVerifyGetKey | CryptoKey | Uint8Array
export function accessSettings(config: ReturnType<typeof useRuntimeConfig>): AccessSettings | null {
const teamDomain = String(
process.env.CF_ACCESS_TEAM_DOMAIN || config.accessTeamDomain || '',
).trim()
const audience = String(process.env.CF_ACCESS_AUDIENCE || config.accessAudience || '').trim()
if (!teamDomain || !audience) return null
return { teamDomain, audience }
}
export function mockAuthEnabled(config: ReturnType<typeof useRuntimeConfig>): boolean {
if (process.env.NODE_ENV !== 'development') return false
return String(process.env.TELEMETRY_ADMIN_MOCK_AUTH || config.mockAuth) === 'true'
}
export function mockScenario(config: ReturnType<typeof useRuntimeConfig>): string {
return String(process.env.TELEMETRY_ADMIN_MOCK_SCENARIO || config.mockScenario || 'normal')
}
export async function verifyAccessJwt(
token: string,
settings: AccessSettings,
key?: VerifyKey,
): Promise<AdminSessionDto> {
const issuer = `https://${settings.teamDomain}.cloudflareaccess.com`
const verifyKey = key ?? createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`))
try {
const { payload } = await jwtVerify(token, verifyKey, {
issuer,
audience: settings.audience,
algorithms: ['RS256'],
})
const email = typeof payload.email === 'string' ? payload.email : null
const nameClaim = payload.name ?? payload.common_name ?? email
const name = typeof nameClaim === 'string' && nameClaim.trim() ? nameClaim : 'GitHub member'
return {
identity: { name, email },
organization: 'Axolotl-Launcher',
logoutUrl: '/cdn-cgi/access/logout',
mock: false,
dataSource: 'production',
}
} catch {
throw unauthorized()
}
}
export async function authenticateAccessToken(
token: string | null | undefined,
settings: AccessSettings,
key?: VerifyKey,
): Promise<AdminSessionDto> {
if (!token) throw unauthorized()
return verifyAccessJwt(token, settings, key)
}
export function mockSession(scenario: string): AdminSessionDto {
if (scenario === 'forbidden') throw forbidden()
if (scenario === 'unconfigured-auth') throw unauthorized()
return {
identity: { name: '本地开发身份', email: null },
organization: 'Axolotl-Launcher',
logoutUrl: '/',
mock: true,
dataSource: 'fixture',
}
}

View File

@ -0,0 +1,11 @@
export interface DashboardBindings {
DB?: D1Database
ERROR_CONTEXTS?: R2Bucket
CLOUDFLARE_ACCOUNT_ID?: string
CLOUDFLARE_ANALYTICS_TOKEN?: string
}
export function dashboardBindings(event: { context: Record<string, unknown> }): DashboardBindings {
const cloudflare = event.context.cloudflare as { env?: DashboardBindings } | undefined
return cloudflare?.env ?? (event.context.env as DashboardBindings | undefined) ?? {}
}

View File

@ -0,0 +1,838 @@
import type {
ActivityDto,
AdminRange,
DistributionItemDto,
DistributionsDto,
ErrorDetailDto,
ErrorFiltersDto,
ErrorRowDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
ServiceCheckDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { unavailable } from './errors'
import { type ErrorQuery, rangeDays, startDay } from './validation'
interface CountRow {
count: number
}
interface OverviewRow {
total_installations: number
dau: number
wau: number
mau: number
new_today: number
error_occurrences: number
distinct_groups: number
r2_today: number
}
interface ActivityRow {
day: string
active_installations: number
new_installations: number
error_occurrences: number
}
interface DistributionRow {
label: string
value: number
}
interface ErrorRow {
fingerprint: string
error_type: string
latest_message: string
app_version: string
first_seen: string
last_seen: string
occurrence_count: number
affected_installations: number
has_sample: number
}
interface SampleRegistration {
object_key: string
}
export interface TelemetryQueryResult<T = Record<string, unknown>> {
results: T[]
}
export interface TelemetryStatement {
bind(...values: unknown[]): TelemetryStatement
first<T = Record<string, unknown>>(): Promise<T | null>
all<T = Record<string, unknown>>(): Promise<TelemetryQueryResult<T>>
}
export interface TelemetryDatabase {
prepare(sql: string): TelemetryStatement
batch(statements: TelemetryStatement[]): Promise<TelemetryQueryResult[]>
}
export interface TelemetryObject {
body: ReadableStream
httpMetadata?: { contentEncoding?: string }
}
export interface TelemetryObjectStore {
get(
key: string,
options?: { range?: { offset: number; length: number } },
): Promise<TelemetryObject | null>
}
const SAMPLE_UNCOMPRESSED_LIMIT = 32 * 1024
const ANALYTICS_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'
const WORKERS_FREE_DAILY_REQUESTS = 100_000
const D1_FREE_DAILY_ROWS_READ = 5_000_000
const D1_FREE_DAILY_ROWS_WRITTEN = 100_000
const R2_FREE_MONTHLY_CLASS_A_OPS = 1_000_000
const USAGE_WARN_RATIO = 0.9
const CACHE_TTL_OVERVIEW = 600_000
const CACHE_TTL_ACTIVITY = 600_000
const CACHE_TTL_DISTRIBUTIONS = 900_000
const CACHE_TTL_ERRORS = 1_800_000
const CACHE_TTL_FILTERS = 900_000
const CACHE_TTL_DETAIL = 600_000
const CACHE_TTL_SAMPLE = 600_000
const CACHE_TTL_SYSTEM = 600_000
export interface CloudflareAnalyticsSettings {
accountTag: string
apiToken: string
}
interface AnalyticsResponse {
data?: Record<string, unknown> | null
errors?: Array<{ message?: string }>
}
interface CacheEntry {
expiresAt: number
value: unknown
}
const responseCache = new Map<string, CacheEntry>()
function utcDay(now = new Date()): string {
return now.toISOString().slice(0, 10)
}
function daysAgoUtc(now: Date, days: number): string {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
return date.toISOString().slice(0, 10)
}
function formatCount(value: number): string {
return Math.round(value).toLocaleString('en-US')
}
function errorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.slice(0, 160)
}
async function withTimeout<T>(
milliseconds: number,
run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), milliseconds)
try {
return await run(controller.signal)
} finally {
clearTimeout(timer)
}
}
async function queryAnalytics(
fetcher: typeof fetch,
settings: CloudflareAnalyticsSettings,
query: string,
): Promise<Record<string, unknown>> {
const response = await withTimeout(10_000, (signal) =>
fetcher(ANALYTICS_ENDPOINT, {
method: 'POST',
headers: {
authorization: `Bearer ${settings.apiToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ query }),
signal,
}),
)
let body: AnalyticsResponse = {}
try {
body = (await response.json()) as AnalyticsResponse
} catch {
// A non-JSON error body is reported through the HTTP status below.
}
if (!response.ok) {
throw new Error(`GraphQL 请求失败HTTP ${response.status}`)
}
if (body.errors?.length) {
throw new Error(
body.errors
.map((entry) => entry.message ?? '未知错误')
.join('')
.slice(0, 160),
)
}
if (!body.data) throw new Error('GraphQL 响应缺少 data')
return body.data
}
function analyticsRows(data: Record<string, unknown>, dataset: string): unknown[] {
const accounts = (data.viewer as { accounts?: unknown } | undefined)?.accounts
if (!Array.isArray(accounts) || accounts.length === 0) return []
const node = (accounts[0] as Record<string, unknown> | undefined)?.[dataset]
return Array.isArray(node) ? node : []
}
function sumOf(row: unknown, path: string[]): number {
let current: unknown = row
for (const field of path) {
if (typeof current !== 'object' || current === null) return 0
current = (current as Record<string, unknown>)[field]
}
const parsed = Number(current)
return Number.isFinite(parsed) ? parsed : 0
}
function requireRow(rows: unknown[]): Record<string, unknown> {
if (rows.length === 0) throw new Error('数据集暂无数据')
return rows[0] as Record<string, unknown>
}
function mapError(row: ErrorRow): ErrorRowDto {
return {
fingerprint: row.fingerprint,
errorType: row.error_type,
latestMessage: row.latest_message,
appVersion: row.app_version,
firstSeen: row.first_seen,
lastSeen: row.last_seen,
occurrenceCount: Number(row.occurrence_count),
affectedInstallations: Number(row.affected_installations),
hasSample: Boolean(row.has_sample),
}
}
function fillActivity(range: AdminRange, rows: ActivityRow[], now = new Date()): ActivityDto {
const values = new Map(rows.map((row) => [row.day, row]))
const start = new Date(`${startDay(range, now)}T00:00:00.000Z`)
const points = Array.from({ length: rangeDays(range) }, (_, index) => {
const day = new Date(start)
day.setUTCDate(start.getUTCDate() + index)
const key = day.toISOString().slice(0, 10)
const row = values.get(key)
return {
day: key,
activeInstallations: Number(row?.active_installations ?? 0),
newInstallations: Number(row?.new_installations ?? 0),
errorOccurrences: Number(row?.error_occurrences ?? 0),
}
})
return { range, points }
}
function service(
status: ServiceCheckDto['status'],
label: string,
detail: string,
): ServiceCheckDto {
return { status, label, detail }
}
export class D1TelemetryAdminApi implements TelemetryAdminApi {
constructor(
private readonly db: TelemetryDatabase,
private readonly r2: TelemetryObjectStore | undefined,
private readonly options: {
storeErrorContext: boolean
healthUrl: string
analytics?: CloudflareAnalyticsSettings
fetcher?: typeof fetch
now?: () => Date
cacheTtlMs?: number
},
) {}
private now(): Date {
return this.options.now?.() ?? new Date()
}
private async cached<T>(key: string, ttlMs: number, run: () => Promise<T>): Promise<T> {
const effectiveTtl = this.options.cacheTtlMs ?? ttlMs
if (effectiveTtl <= 0) return run()
const entry = responseCache.get(key)
if (entry && entry.expiresAt > Date.now()) return entry.value as T
const value = await run()
if (responseCache.size > 256) {
for (const [cachedKey, cachedEntry] of responseCache) {
if (cachedEntry.expiresAt <= Date.now()) responseCache.delete(cachedKey)
}
}
responseCache.set(key, { expiresAt: Date.now() + effectiveTtl, value })
return value
}
async overview(range: AdminRange): Promise<OverviewDto> {
return this.cached(`overview:${range}`, CACHE_TTL_OVERVIEW, async () => {
const today = utcDay(this.now())
const start = startDay(range, this.now())
const yesterday = daysAgoUtc(this.now(), 1)
const groupCountSql =
range === '365d'
? '(SELECT COUNT(*) FROM error_groups)'
: '(SELECT COUNT(*) FROM error_range_stats WHERE range_days = ?)'
const groupCountBindings = range === '365d' ? [] : [rangeDays(range)]
const row = await this.db
.prepare(
`SELECT
(SELECT COUNT(*) FROM installations) AS total_installations,
(SELECT COUNT(*) FROM daily_active WHERE day = ?) AS dau,
(SELECT COUNT(DISTINCT installation_hash) FROM daily_active
WHERE day >= date(?, '-6 days') AND day <= ?) AS wau,
(SELECT COUNT(DISTINCT installation_hash) FROM daily_active
WHERE day >= date(?, '-29 days') AND day <= ?) AS mau,
(SELECT COUNT(*) FROM installations WHERE first_seen_day = ?) AS new_today,
(SELECT COALESCE(SUM(error_occurrences), 0) FROM daily_totals
WHERE day >= ? AND day <= ?)
+ (SELECT COALESCE(SUM(occurrence_count), 0) FROM error_daily WHERE day = ?) AS error_occurrences,
${groupCountSql}
+ (SELECT COUNT(*) FROM error_daily WHERE day = ?) AS distinct_groups,
(SELECT COALESCE(object_count, 0) FROM error_context_budget WHERE day = ?) AS r2_today`,
)
.bind(today, today, today, today, today, today, start, yesterday, today, ...groupCountBindings, today, today)
.first<OverviewRow>()
if (!row) throw unavailable()
const metric = (value: number, label: string) => ({ value: Number(value), label })
return {
range,
generatedAt: this.now().toISOString(),
metrics: {
totalInstallations: metric(row.total_installations, '历史累计主动同意遥测的安装'),
dau: metric(row.dau, '今日 UTC 唯一活跃安装'),
wau: metric(row.wau, '最近 7 天唯一活跃安装'),
mau: metric(row.mau, '最近 30 天唯一活跃安装'),
newInstallationsToday: metric(row.new_today, '今日 UTC 首次出现'),
errorOccurrences: metric(row.error_occurrences, `${range} 范围内发生次数`),
distinctErrorGroups: metric(row.distinct_groups, `${range} 范围内错误指纹`),
r2SamplesToday: metric(row.r2_today, '今日 UTC 已存储样本'),
},
}
})
}
async activity(range: AdminRange): Promise<ActivityDto> {
return this.cached(`activity:${range}`, CACHE_TTL_ACTIVITY, async () => {
const today = utcDay(this.now())
const yesterday = daysAgoUtc(this.now(), 1)
const rows = await this.db
.prepare(
`SELECT day, active_installations, new_installations, error_occurrences
FROM daily_totals WHERE day >= ? AND day <= ? ORDER BY day ASC`,
)
.bind(startDay(range, this.now()), yesterday)
.all<ActivityRow>()
const todayRow = await this.db
.prepare(
`SELECT
(SELECT COUNT(*) FROM daily_active WHERE day = ?) AS active_installations,
(SELECT COUNT(*) FROM installations WHERE first_seen_day = ?) AS new_installations,
(SELECT COALESCE(SUM(occurrence_count), 0) FROM error_daily WHERE day = ?) AS error_occurrences`,
)
.bind(today, today, today)
.first<ActivityRow>()
const allRows = todayRow ? [...rows.results, todayRow] : rows.results
return fillActivity(range, allRows, this.now())
})
}
async distributions(range: AdminRange): Promise<DistributionsDto> {
return this.cached(`distributions:${range}`, CACHE_TTL_DISTRIBUTIONS, async () => {
const start = startDay(range, this.now())
const today = utcDay(this.now())
const query = async (
field: 'app_version' | 'platform' | 'arch',
): Promise<DistributionItemDto[]> => {
const result = await this.db
.prepare(
`SELECT ${field} AS label, COUNT(DISTINCT installation_hash) AS value
FROM daily_active WHERE day >= ? AND day <= ?
GROUP BY ${field} ORDER BY value DESC, label ASC LIMIT 12`,
)
.bind(start, today)
.all<DistributionRow>()
return result.results.map((row) => ({ label: row.label, value: Number(row.value) }))
}
const [versions, platforms, architectures] = await Promise.all([
query('app_version'),
query('platform'),
query('arch'),
])
return { range, versions, platforms, architectures }
})
}
async errors(query: ErrorQuery): Promise<ErrorsPageDto> {
return this.cached(`errors:${JSON.stringify(query)}`, CACHE_TTL_ERRORS, async () => {
const today = utcDay(this.now())
const start = startDay(query.range, this.now())
const rangeSource =
query.range === '365d'
? {
sql: `SELECT fingerprint, app_version, first_seen_day AS first_seen, last_seen_day AS last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
CASE WHEN sample_object_key IS NULL THEN 0 ELSE 1 END AS has_sample
FROM error_groups`,
bindings: [] as unknown[],
}
: {
sql: `SELECT fingerprint, app_version, first_seen, last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
has_sample
FROM error_range_stats WHERE range_days = ?`,
bindings: [rangeDays(query.range)] as unknown[],
}
const liveSql = `SELECT ed.fingerprint, ed.app_version, ed.day AS first_seen, ed.day AS last_seen,
SUM(ed.occurrence_count) AS occurrence_count,
SUM(ed.installation_count) AS installation_count,
MAX(ed.latest_error_type) AS latest_error_type,
MAX(ed.latest_message) AS latest_message,
MAX(ed.has_sample) AS has_sample
FROM error_daily ed WHERE ed.day = ?
GROUP BY ed.fingerprint, ed.app_version`
const scopedSql = `SELECT fingerprint, app_version, first_seen, last_seen,
occurrence_count, installation_count, latest_error_type, latest_message,
has_sample
FROM (${rangeSource.sql})
UNION ALL
${liveSql}`
const bindings: unknown[] = [...rangeSource.bindings, today]
const conditions: string[] = []
if (query.search) {
const escaped = query.search.toLowerCase().replace(/[\\%_]/g, '\\$&')
conditions.push(
"(LOWER(scoped.fingerprint) LIKE ? ESCAPE '\\' OR LOWER(scoped.latest_message) LIKE ? ESCAPE '\\' OR LOWER(scoped.latest_error_type) LIKE ? ESCAPE '\\')",
)
bindings.push(`%${escaped}%`, `%${escaped}%`, `%${escaped}%`)
}
if (query.version) {
conditions.push('scoped.app_version = ?')
bindings.push(query.version)
}
if (query.errorType) {
conditions.push('scoped.latest_error_type = ?')
bindings.push(query.errorType)
}
if (query.platform) {
conditions.push(
`EXISTS (SELECT 1 FROM error_reports er
WHERE er.fingerprint = scoped.fingerprint AND er.app_version = scoped.app_version
AND er.platform = ? AND er.day >= ? AND er.day <= ?)`,
)
bindings.push(query.platform, start, today)
}
if (query.hasSample !== null) {
conditions.push(query.hasSample ? 'scoped.has_sample = 1' : 'scoped.has_sample = 0')
}
const where = conditions.length ? conditions.join(' AND ') : '1 = 1'
const totalRow = await this.db
.prepare(
`WITH scoped AS (${scopedSql})
SELECT COUNT(*) AS count FROM (
SELECT fingerprint FROM scoped WHERE ${where} GROUP BY fingerprint
)`,
)
.bind(...bindings)
.first<CountRow>()
const sortColumns: Record<ErrorQuery['sort'], string> = {
lastSeen: 'last_seen',
firstSeen: 'first_seen',
occurrences: 'occurrence_count',
installations: 'affected_installations',
}
const direction = query.direction === 'asc' ? 'ASC' : 'DESC'
const offset = (query.page - 1) * query.pageSize
const rows = await this.db
.prepare(
`WITH scoped AS (${scopedSql})
SELECT
fingerprint,
MIN(first_seen) AS first_seen,
MAX(last_seen) AS last_seen,
SUM(occurrence_count) AS occurrence_count,
SUM(installation_count) AS affected_installations,
COALESCE((SELECT eg.latest_error_type FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(latest_error_type)) AS error_type,
COALESCE((SELECT eg.latest_message FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(latest_message)) AS latest_message,
COALESCE((SELECT eg.app_version FROM error_groups eg
WHERE eg.fingerprint = scoped.fingerprint
ORDER BY eg.last_seen_day DESC LIMIT 1), MAX(app_version)) AS app_version,
MAX(has_sample) AS has_sample
FROM scoped
WHERE ${where}
GROUP BY fingerprint
ORDER BY ${sortColumns[query.sort]} ${direction}, fingerprint ASC
LIMIT ? OFFSET ?`,
)
.bind(...bindings, query.pageSize, offset)
.all<ErrorRow>()
const filters = await this.errorFilters()
const total = Number(totalRow?.count ?? 0)
return {
items: rows.results.map(mapError),
page: query.page,
pageSize: query.pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
filters,
}
})
}
private async errorFilters(): Promise<ErrorFiltersDto> {
return this.cached('errorFilters', CACHE_TTL_FILTERS, async () => {
const [versions, platforms, errorTypes] = await Promise.all([
this.db
.prepare(
'SELECT DISTINCT app_version AS value FROM error_groups ORDER BY value DESC LIMIT 100',
)
.all<{ value: string }>(),
this.db
.prepare('SELECT platform AS value FROM platforms ORDER BY value ASC LIMIT 100')
.all<{ value: string }>(),
this.db
.prepare(
'SELECT DISTINCT latest_error_type AS value FROM error_groups ORDER BY value ASC LIMIT 100',
)
.all<{ value: string }>(),
])
return {
versions: versions.results.map((row) => row.value),
platforms: platforms.results.map((row) => row.value),
errorTypes: errorTypes.results.map((row) => row.value),
}
})
}
async errorDetail(fingerprint: string): Promise<ErrorDetailDto | null> {
return this.cached(`errorDetail:${fingerprint}`, CACHE_TTL_DETAIL, async () => {
const row = await this.db
.prepare(
`SELECT fingerprint, latest_error_type AS error_type, latest_message,
app_version, first_seen_day AS first_seen, last_seen_day AS last_seen,
occurrence_count, installation_count AS affected_installations,
CASE WHEN sample_object_key IS NULL THEN 0 ELSE 1 END AS has_sample
FROM error_groups WHERE fingerprint = ? ORDER BY last_seen_day DESC LIMIT 1`,
)
.bind(fingerprint)
.first<ErrorRow>()
if (row) return { ...mapError(row), route: null, command: null, stack: null }
const live = await this.db
.prepare(
`SELECT
ed.fingerprint,
MAX(ed.latest_error_type) AS error_type,
MAX(ed.latest_message) AS latest_message,
ed.app_version,
MIN(ed.day) AS first_seen,
MAX(ed.day) AS last_seen,
SUM(ed.occurrence_count) AS occurrence_count,
SUM(ed.installation_count) AS affected_installations,
MAX(ed.has_sample) AS has_sample
FROM error_daily ed
WHERE ed.fingerprint = ?
GROUP BY ed.fingerprint, ed.app_version
ORDER BY last_seen DESC LIMIT 1`,
)
.bind(fingerprint)
.first<ErrorRow>()
if (!live) return null
return { ...mapError(live), route: null, command: null, stack: null }
})
}
async errorSample(fingerprint: string): Promise<ErrorSampleDto | null> {
if (!this.r2) return null
return this.cached(`errorSample:${fingerprint}`, CACHE_TTL_SAMPLE, async () => {
const registration = await this.db
.prepare(
`SELECT object_key FROM error_context_reservations
WHERE fingerprint = ?
ORDER BY created_at DESC LIMIT 1`,
)
.bind(fingerprint)
.first<SampleRegistration>()
if (!registration) return null
const object = await this.r2!.get(registration.object_key)
if (!object?.body) return null
let stream: ReadableStream = object.body
if (
object.httpMetadata?.contentEncoding === 'gzip' ||
registration.object_key.endsWith('.gz')
) {
stream = stream.pipeThrough(new DecompressionStream('gzip'))
}
const bytes = await new Response(stream).arrayBuffer()
if (bytes.byteLength > SAMPLE_UNCOMPRESSED_LIMIT) throw unavailable()
let value: Record<string, unknown>
try {
value = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
} catch {
throw unavailable()
}
const app =
typeof value.app === 'object' && value.app ? (value.app as Record<string, unknown>) : {}
const text = (input: unknown, limit: number): string =>
typeof input === 'string' ? input.slice(0, limit) : ''
const optional = (input: unknown, limit: number): string | null => text(input, limit) || null
return {
fingerprint,
occurredAt: text(value.occurred_at, 64),
appVersion: text(app.version, 64),
platform: text(app.platform, 32),
architecture: text(app.arch, 32),
errorType: text(value.error_type, 128),
message: text(value.message, 1_024),
stack: optional(value.stack, 8_192),
route: optional(value.route, 256),
command: optional(value.command, 256),
context: optional(value.context, 16_384),
}
})
}
private async workersRequests24h(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<number> {
const start = new Date(now.getTime() - 24 * 60 * 60 * 1_000).toISOString()
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
workersInvocationsAdaptive(
filter: { datetime_geq: "${start}", datetime_lt: "${now.toISOString()}" }
limit: 1
) { sum { requests } }
} } }`,
)
return sumOf(requireRow(analyticsRows(data, 'workersInvocationsAdaptive')), ['sum', 'requests'])
}
private async d1UsageToday(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<{ rowsRead: number; rowsWritten: number }> {
const today = utcDay(now)
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
d1AnalyticsAdaptiveGroups(
filter: { date_geq: "${today}", date_leq: "${today}" }
limit: 1
) { sum { rowsRead rowsWritten } }
} } }`,
)
const row = requireRow(analyticsRows(data, 'd1AnalyticsAdaptiveGroups'))
return {
rowsRead: sumOf(row, ['sum', 'rowsRead']),
rowsWritten: sumOf(row, ['sum', 'rowsWritten']),
}
}
private async r2Operations30d(
settings: CloudflareAnalyticsSettings,
fetcher: typeof fetch,
now: Date,
): Promise<number> {
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000).toISOString().slice(0, 10)
const data = await queryAnalytics(
fetcher,
settings,
`{ viewer { accounts(filter: { accountTag: "${settings.accountTag}" }) {
r2OperationsAdaptiveGroups(
filter: { date_geq: "${start}", date_leq: "${utcDay(now)}" }
limit: 1
) { sum { requests } }
} } }`,
)
return sumOf(requireRow(analyticsRows(data, 'r2OperationsAdaptiveGroups')), ['sum', 'requests'])
}
private async accountUsage(settings: CloudflareAnalyticsSettings): Promise<ServiceCheckDto> {
if (!/^[0-9a-f]{32}$/i.test(settings.accountTag)) {
return service('unavailable', '未配置', 'CLOUDFLARE_ACCOUNT_ID 格式无效(应为 32 位账户 ID')
}
const fetcher = this.options.fetcher ?? fetch
const now = this.now()
const parts: string[] = []
const failures: string[] = []
let workersRatio = 0
let d1WriteRatio = 0
let r2Ratio = 0
try {
const requests = await this.workersRequests24h(settings, fetcher, now)
workersRatio = requests / WORKERS_FREE_DAILY_REQUESTS
parts.push(
`Workers ${formatCount(requests)}/${formatCount(WORKERS_FREE_DAILY_REQUESTS)} 请求24h`,
)
} catch (error) {
failures.push(`Workers 用量查询失败:${errorMessage(error)}`)
}
try {
const { rowsRead, rowsWritten } = await this.d1UsageToday(settings, fetcher, now)
d1WriteRatio = rowsWritten / D1_FREE_DAILY_ROWS_WRITTEN
parts.push(
`D1 读 ${formatCount(rowsRead)}/${formatCount(D1_FREE_DAILY_ROWS_READ)}、写 ${formatCount(rowsWritten)}/${formatCount(D1_FREE_DAILY_ROWS_WRITTEN)} 行(今日 UTC`,
)
} catch (error) {
failures.push(`D1 用量查询失败:${errorMessage(error)}`)
}
try {
const requests = await this.r2Operations30d(settings, fetcher, now)
r2Ratio = requests / R2_FREE_MONTHLY_CLASS_A_OPS
parts.push(
`R2 ${formatCount(requests)}/${formatCount(R2_FREE_MONTHLY_CLASS_A_OPS)} 操作30 天Class A+B 合计)`,
)
} catch (error) {
failures.push(`R2 用量查询失败:${errorMessage(error)}`)
}
if (parts.length === 0) {
return service('unavailable', '查询失败', failures.join('') || 'Analytics API 无可用数据')
}
const detail = failures.length
? `${parts.join(' · ')}${failures.join('')}`
: parts.join(' · ')
if (failures.length > 0) return service('degraded', '部分可用', detail)
if (Math.max(workersRatio, d1WriteRatio, r2Ratio) >= USAGE_WARN_RATIO) {
return service('degraded', '接近配额', detail)
}
return service('available', '额度充足', detail)
}
async system(): Promise<SystemDto> {
return this.cached('system', CACHE_TTL_SYSTEM, async () => {
const now = this.now()
let publicWorker = service('unavailable', '不可用', '健康检查端点未响应')
try {
const response = await withTimeout(3_000, (signal) =>
(this.options.fetcher ?? fetch)(this.options.healthUrl, {
signal,
headers: { accept: 'application/json' },
}),
)
if (response.ok) publicWorker = service('available', '运行正常', '公开采集服务健康检查通过')
} catch {
publicWorker = service('unavailable', '不可用', '健康检查端点未响应')
}
let d1 = service('unavailable', '不可用', 'D1 查询失败')
let latestDataDay: string | null = null
let budget = 0
let sampleKey: string | null = null
try {
const result = await this.db.batch([
this.db.prepare('SELECT MAX(day) AS value FROM daily_totals'),
this.db
.prepare(
'SELECT COALESCE(object_count, 0) AS value FROM error_context_budget WHERE day = ?',
)
.bind(utcDay(now)),
this.db.prepare(
'SELECT object_key AS value FROM error_context_reservations ORDER BY created_at DESC LIMIT 1',
),
])
latestDataDay =
(result[0].results[0] as { value?: string | null } | undefined)?.value ?? null
budget = Number((result[1].results[0] as { value?: number } | undefined)?.value ?? 0)
sampleKey = (result[2].results[0] as { value?: string | null } | undefined)?.value ?? null
d1 = service('available', '可查询', '只读遥测查询可用')
} catch {
d1 = service('unavailable', '不可用', 'D1 查询失败')
}
let r2 = service('unavailable', '不可用', '没有可读取的登记样本')
if (!this.options.storeErrorContext) {
r2 = service('degraded', '已停用', '错误上下文存储已停用')
} else if (this.r2 && sampleKey) {
try {
const sample = await this.r2.get(sampleKey, { range: { offset: 0, length: 1 } })
if (sample) {
try {
await sample.body.cancel()
} catch {
// The probe reads a single byte; cancellation failures are irrelevant.
}
r2 = service('available', '可读取', '已确认一个登记样本可读取')
}
} catch {
r2 = service('unavailable', '不可用', '登记样本无法读取')
}
} else if (this.r2) {
r2 = service('degraded', '暂无样本', 'R2 binding 正常,但当前没有登记样本')
}
const yesterday = daysAgoUtc(now, 1)
const cron = !latestDataDay
? service('unavailable', '不可用', '没有可用的聚合日期')
: latestDataDay >= yesterday
? service('available', '数据最新', `最近聚合日期:${latestDataDay}UTC`)
: service('degraded', '数据滞后', `最近聚合日期:${latestDataDay}UTC`)
const accountUsage = !this.options.analytics
? service(
'unavailable',
'未配置',
'尚未配置 Cloudflare Analytics API请为 dashboard Worker 设置 CLOUDFLARE_ACCOUNT_ID 与 CLOUDFLARE_ANALYTICS_TOKENAccount Analytics: Read',
)
: await this.accountUsage(this.options.analytics)
return {
generatedAt: now.toISOString(),
publicWorker,
d1,
r2,
storeErrorContext: this.options.storeErrorContext,
r2Budget: { used: budget, limit: 2000 },
limits: {
samplesPerGroup: 3,
dailyActiveRetentionDays: 35,
errorReportsRetentionDays: 30,
r2RetentionDays: 30,
errorAggregatesRetentionDays: 365,
},
latestDataDay,
cron,
accountUsage,
}
})
}
}
export { fillActivity }

View File

@ -0,0 +1,14 @@
export class AdminApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message)
this.name = 'AdminApiError'
}
}
export const unauthorized = () => new AdminApiError(401, 'unauthenticated', '需要登录')
export const forbidden = () => new AdminApiError(403, 'forbidden', '当前身份无权访问')
export const unavailable = () => new AdminApiError(503, 'service_unavailable', '遥测数据暂不可用')

View File

@ -0,0 +1,33 @@
import {
createError,
defineEventHandler,
type EventHandler,
type EventHandlerRequest,
isError,
} from 'h3'
import { toAdminHttpError } from './admin-http-error'
import { AdminApiError } from './errors'
export function defineAdminHandler<T>(
handler: EventHandler<EventHandlerRequest, T | Promise<T>>,
): EventHandler<EventHandlerRequest, Promise<T>> {
return defineEventHandler(async (event) => {
try {
return await handler(event)
} catch (error) {
if (error instanceof AdminApiError) throw toAdminHttpError(error)
if (isError(error) && 'statusCode' in error && Number(error.statusCode) < 500) throw error
throw createError({
statusCode: 503,
statusMessage: '遥测数据暂不可用',
data: {
error: {
code: 'service_unavailable',
message: '遥测数据暂不可用',
},
},
})
}
})
}

View File

@ -0,0 +1,232 @@
import type {
ActivityDto,
AdminRange,
DistributionsDto,
ErrorDetailDto,
ErrorSampleDto,
ErrorsPageDto,
OverviewDto,
SystemDto,
} from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { unavailable } from './errors'
import type { ErrorQuery } from './validation'
import { rangeDays } from './validation'
const FIXTURE_NOW = new Date('2026-08-14T10:00:00.000Z')
const fixtureErrors: ErrorDetailDto[] = [
{
fingerprint: 'fixture-render-thread-01',
errorType: 'RenderInitializationError',
latestMessage: 'Fixture renderer could not initialize the selected backend.',
appVersion: '9.4.0-fixture',
firstSeen: '2026-08-03',
lastSeen: '2026-08-14',
occurrenceCount: 184,
affectedInstallations: 41,
hasSample: true,
route: null,
command: null,
stack: null,
},
{
fingerprint: 'fixture-metadata-timeout-02',
errorType: 'MetadataTimeout',
latestMessage: 'Fixture metadata request exceeded its deadline.',
appVersion: '9.3.2-fixture',
firstSeen: '2026-08-09',
lastSeen: '2026-08-13',
occurrenceCount: 57,
affectedInstallations: 19,
hasSample: false,
route: null,
command: null,
stack: null,
},
{
fingerprint: 'fixture-runtime-path-03',
errorType: 'RuntimePathError',
latestMessage: 'Fixture Java runtime path was not available.',
appVersion: '9.4.0-fixture',
firstSeen: '2026-08-12',
lastSeen: '2026-08-12',
occurrenceCount: 12,
affectedInstallations: 8,
hasSample: true,
route: null,
command: null,
stack: null,
},
]
export class MockTelemetryAdminApi implements TelemetryAdminApi {
constructor(private readonly scenario = 'normal') {}
private assertAvailable(): void {
if (this.scenario === 'api-error') throw unavailable()
}
async overview(range: AdminRange): Promise<OverviewDto> {
this.assertAvailable()
const empty = this.scenario === 'empty'
const value = (normal: number, label: string) => ({ value: empty ? 0 : normal, label })
return {
range,
generatedAt: FIXTURE_NOW.toISOString(),
metrics: {
totalInstallations: value(18_426, '历史累计主动同意遥测的安装'),
dau: value(2_184, '今日 UTC 唯一活跃安装'),
wau: value(7_932, '最近 7 天唯一活跃安装'),
mau: value(14_806, '最近 30 天唯一活跃安装'),
newInstallationsToday: value(318, '今日 UTC 首次出现'),
errorOccurrences: value(476, `${range} 范围内发生次数`),
distinctErrorGroups: value(39, `${range} 范围内错误指纹`),
r2SamplesToday: value(
this.scenario === 'budget-reached' ? 2_000 : 614,
'今日 UTC 已存储样本',
),
},
}
}
async activity(range: AdminRange): Promise<ActivityDto> {
this.assertAvailable()
const points = Array.from({ length: rangeDays(range) }, (_, index) => {
const day = new Date(FIXTURE_NOW)
day.setUTCDate(day.getUTCDate() - rangeDays(range) + index + 1)
const wave = Math.round(Math.sin(index / 3) * 90)
return {
day: day.toISOString().slice(0, 10),
activeInstallations: this.scenario === 'empty' ? 0 : 1_820 + index * 9 + wave,
newInstallations: this.scenario === 'empty' ? 0 : 230 + (index % 6) * 17,
errorOccurrences: this.scenario === 'empty' ? 0 : 20 + (index % 5) * 8,
}
})
return { range, points }
}
async distributions(range: AdminRange): Promise<DistributionsDto> {
this.assertAvailable()
if (this.scenario === 'empty') return { range, versions: [], platforms: [], architectures: [] }
return {
range,
versions: [
{ label: '9.4.0-fixture', value: 8_214 },
{ label: '9.3.2-fixture', value: 4_981 },
{ label: '9.3.1-fixture', value: 1_760 },
],
platforms: [
{ label: 'windows-fixture', value: 10_840 },
{ label: 'linux-fixture', value: 2_934 },
{ label: 'macos-fixture', value: 1_181 },
],
architectures: [
{ label: 'x86_64-fixture', value: 12_902 },
{ label: 'aarch64-fixture', value: 2_053 },
],
}
}
async errors(query: ErrorQuery): Promise<ErrorsPageDto> {
this.assertAvailable()
let rows = this.scenario === 'empty' ? [] : [...fixtureErrors]
if (this.scenario === 'no-sample') rows = rows.map((row) => ({ ...row, hasSample: false }))
const search = query.search.toLowerCase()
rows = rows.filter(
(row) =>
(!search ||
`${row.fingerprint} ${row.errorType} ${row.latestMessage}`
.toLowerCase()
.includes(search)) &&
(!query.version || row.appVersion === query.version) &&
(!query.errorType || row.errorType === query.errorType) &&
(query.hasSample === null || row.hasSample === query.hasSample),
)
const keys: Record<ErrorQuery['sort'], keyof ErrorDetailDto> = {
lastSeen: 'lastSeen',
firstSeen: 'firstSeen',
occurrences: 'occurrenceCount',
installations: 'affectedInstallations',
}
rows.sort((left, right) => {
const a = left[keys[query.sort]]
const b = right[keys[query.sort]]
const compared =
typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b))
return query.direction === 'asc' ? compared : -compared
})
const total = rows.length
const start = (query.page - 1) * query.pageSize
return {
items: rows.slice(start, start + query.pageSize),
page: query.page,
pageSize: query.pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / query.pageSize)),
filters: {
versions: ['9.4.0-fixture', '9.3.2-fixture', '9.3.1-fixture'],
platforms: ['windows-fixture', 'linux-fixture', 'macos-fixture'],
errorTypes: ['MetadataTimeout', 'RenderInitializationError', 'RuntimePathError'],
},
}
}
async errorDetail(fingerprint: string): Promise<ErrorDetailDto | null> {
this.assertAvailable()
return fixtureErrors.find((row) => row.fingerprint === fingerprint) ?? null
}
async errorSample(fingerprint: string): Promise<ErrorSampleDto | null> {
this.assertAvailable()
const error = fixtureErrors.find((row) => row.fingerprint === fingerprint)
if (!error?.hasSample || this.scenario === 'no-sample') return null
return {
fingerprint,
occurredAt: '2026-08-14T08:42:11.000Z',
appVersion: error.appVersion,
platform: 'windows-fixture',
architecture: 'x86_64-fixture',
errorType: error.errorType,
message: error.latestMessage,
stack: 'FixtureError: synthetic stack\n at fixture.operation (fixture.ts:14:2)',
route: '/fixture/library',
command: 'fixture-command',
context: '{"fixture":true,"note":"synthetic telemetry context"}',
}
}
async system(): Promise<SystemDto> {
this.assertAvailable()
const budget = this.scenario === 'budget-reached' ? 2_000 : 614
return {
generatedAt: FIXTURE_NOW.toISOString(),
publicWorker: {
status: 'available',
label: '运行正常',
detail: '模拟健康检查通过',
},
d1: { status: 'available', label: '可查询', detail: '模拟 D1 可查询' },
r2:
this.scenario === 'no-sample'
? { status: 'degraded', label: '暂无样本', detail: '没有登记模拟样本' }
: { status: 'available', label: '可读取', detail: '模拟样本可读取' },
storeErrorContext: true,
r2Budget: { used: budget, limit: 2_000 },
limits: {
samplesPerGroup: 3,
dailyActiveRetentionDays: 35,
errorReportsRetentionDays: 30,
r2RetentionDays: 30,
errorAggregatesRetentionDays: 365,
},
latestDataDay: '2026-08-14',
cron: { status: 'available', label: '数据最新', detail: '最近聚合日期2026-08-14UTC' },
accountUsage: {
status: 'unavailable',
label: '不可用',
detail: '尚未配置 Analytics API',
},
}
}
}

View File

@ -0,0 +1,48 @@
import type { H3Event } from 'h3'
import type { AdminSessionDto } from '../../shared/types/telemetry'
import type { TelemetryAdminApi } from './admin-api'
import { mockScenario } from './auth'
import { dashboardBindings } from './bindings'
import {
D1TelemetryAdminApi,
type TelemetryDatabase,
} from './d1-admin-api'
import { unavailable } from './errors'
import { MockTelemetryAdminApi } from './mock-admin-api'
import { remoteTelemetryDataSource } from './vercel-data-source'
export interface AdminEventContext {
adminSession?: AdminSessionDto
adminApi?: TelemetryAdminApi
}
export function requireSession(event: H3Event): AdminSessionDto {
const session = (event.context as AdminEventContext).adminSession
if (!session) throw unavailable()
return session
}
export function getAdminApi(event: H3Event): TelemetryAdminApi {
const context = event.context as AdminEventContext
if (context.adminApi) return context.adminApi
const config = useRuntimeConfig(event)
const bindings = dashboardBindings(event as unknown as { context: Record<string, unknown> })
const remote = remoteTelemetryDataSource()
const db = (bindings.DB as unknown as TelemetryDatabase | undefined) ?? remote?.db
const accountTag = String(bindings.CLOUDFLARE_ACCOUNT_ID ?? '').trim()
const analyticsToken = String(bindings.CLOUDFLARE_ANALYTICS_TOKEN ?? '').trim()
if (db) {
context.adminApi = new D1TelemetryAdminApi(db, undefined, {
storeErrorContext: false,
healthUrl: String(config.publicWorkerHealthUrl),
analytics:
accountTag && analyticsToken ? { accountTag, apiToken: analyticsToken } : undefined,
})
} else if (context.adminSession?.mock) {
context.adminApi = new MockTelemetryAdminApi(mockScenario(config))
} else {
throw unavailable()
}
return context.adminApi
}

View File

@ -0,0 +1,99 @@
import type { H3Event } from 'h3'
import {
ADMIN_RANGES,
type AdminRange,
type ErrorSort,
type SortDirection,
} from '../../shared/types/telemetry'
import { AdminApiError } from './errors'
const ERROR_SORTS = ['lastSeen', 'firstSeen', 'occurrences', 'installations'] as const
const SORT_DIRECTIONS = ['asc', 'desc'] as const
export interface ErrorQuery {
range: AdminRange
page: number
pageSize: number
search: string
version: string | null
platform: string | null
errorType: string | null
hasSample: boolean | null
sort: ErrorSort
direction: SortDirection
}
function single(value: unknown): string | undefined {
return Array.isArray(value) ? String(value[0]) : value == null ? undefined : String(value)
}
function integer(value: unknown, fallback: number, minimum: number, maximum: number): number {
const raw = single(value)
if (raw === undefined || raw === '') return fallback
if (!/^\d+$/.test(raw)) throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
const parsed = Number(raw)
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return parsed
}
function limited(value: unknown, maximum: number): string | null {
const parsed = single(value)?.trim() ?? ''
if (!parsed) return null
if (parsed.length > maximum) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return parsed
}
export function parseRange(value: unknown): AdminRange {
const parsed = single(value) ?? '30d'
if (!ADMIN_RANGES.includes(parsed as AdminRange)) {
throw new AdminApiError(400, 'invalid_range', 'Range must be 7d, 30d, 90d, or 365d')
}
return parsed as AdminRange
}
export function parseErrorQuery(query: Record<string, unknown>): ErrorQuery {
const sort = single(query.sort) ?? 'lastSeen'
const direction = single(query.direction) ?? 'desc'
if (
!ERROR_SORTS.includes(sort as ErrorSort) ||
!SORT_DIRECTIONS.includes(direction as SortDirection)
) {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
const sample = single(query.hasSample)
if (sample !== undefined && sample !== 'true' && sample !== 'false') {
throw new AdminApiError(400, 'invalid_query', 'Invalid query parameters')
}
return {
range: parseRange(query.range),
page: integer(query.page, 1, 1, 100_000),
pageSize: integer(query.pageSize, 25, 1, 100),
search: limited(query.search, 120) ?? '',
version: limited(query.version, 64),
platform: limited(query.platform, 32),
errorType: limited(query.errorType, 128),
hasSample: sample === undefined ? null : sample === 'true',
sort: sort as ErrorSort,
direction: direction as SortDirection,
}
}
export function getQueryRecord(event: H3Event): Record<string, unknown> {
const url = new URL(event.node.req.url ?? '/', 'http://localhost')
return Object.fromEntries(url.searchParams.entries())
}
export function rangeDays(range: AdminRange): number {
return Number.parseInt(range, 10)
}
export function startDay(range: AdminRange, now = new Date()): string {
const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
date.setUTCDate(date.getUTCDate() - rangeDays(range) + 1)
return date.toISOString().slice(0, 10)
}

View File

@ -0,0 +1,153 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type {
TelemetryDatabase,
TelemetryObject,
TelemetryObjectStore,
TelemetryQueryResult,
TelemetryStatement,
} from './d1-admin-api'
interface D1ApiResponse<T> {
success: boolean
errors?: Array<{ message?: string }>
result?: Array<{ results?: T[]; success?: boolean }>
}
interface RemoteSettings {
accountId: string
databaseId: string
apiToken: string
r2AccessKeyId: string
r2SecretAccessKey: string
r2BucketName: string
}
function setting(name: string): string {
return String(process.env[name] ?? '').trim()
}
function remoteSettings(): RemoteSettings | null {
if (process.env.NODE_ENV !== 'production') return null
const settings = {
accountId: setting('CLOUDFLARE_ACCOUNT_ID'),
databaseId: setting('CLOUDFLARE_D1_DATABASE_ID'),
apiToken: setting('CLOUDFLARE_API_TOKEN'),
r2AccessKeyId: setting('CLOUDFLARE_R2_ACCESS_KEY_ID'),
r2SecretAccessKey: setting('CLOUDFLARE_R2_SECRET_ACCESS_KEY'),
r2BucketName: setting('CLOUDFLARE_R2_BUCKET_NAME'),
}
return Object.values(settings).every(Boolean) ? settings : null
}
function assertReadOnlySql(sql: string): void {
if (!/^\s*(SELECT|WITH)\b/i.test(sql)) throw new Error('Telemetry database is read-only')
}
class RemoteD1Statement implements TelemetryStatement {
constructor(
private readonly database: RemoteD1Database,
private readonly sql: string,
private readonly params: unknown[] = [],
) {}
bind(...values: unknown[]): TelemetryStatement {
return new RemoteD1Statement(this.database, this.sql, values)
}
async first<T = Record<string, unknown>>(): Promise<T | null> {
const result = await this.all<T>()
return result.results[0] ?? null
}
all<T = Record<string, unknown>>(): Promise<TelemetryQueryResult<T>> {
return this.database.query<T>(this.sql, this.params)
}
}
class RemoteD1Database implements TelemetryDatabase {
constructor(private readonly settings: RemoteSettings) {}
prepare(sql: string): TelemetryStatement {
return new RemoteD1Statement(this, sql)
}
async batch(statements: TelemetryStatement[]): Promise<TelemetryQueryResult[]> {
return Promise.all(statements.map((statement) => statement.all()))
}
async query<T>(sql: string, params: unknown[]): Promise<TelemetryQueryResult<T>> {
assertReadOnlySql(sql)
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(this.settings.accountId)}/d1/database/${encodeURIComponent(this.settings.databaseId)}/query`,
{
method: 'POST',
headers: {
authorization: `Bearer ${this.settings.apiToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ sql, params }),
signal: AbortSignal.timeout(10_000),
},
)
const body = (await response.json()) as D1ApiResponse<T>
const result = body.result?.[0]
if (!response.ok || !body.success || !result?.success || !result.results) {
throw new Error(body.errors?.[0]?.message || 'Cloudflare D1 query failed')
}
return { results: result.results }
}
}
class RemoteR2Store implements TelemetryObjectStore {
private readonly client: S3Client
constructor(private readonly settings: RemoteSettings) {
this.client = new S3Client({
region: 'auto',
endpoint: `https://${settings.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: settings.r2AccessKeyId,
secretAccessKey: settings.r2SecretAccessKey,
},
})
}
async get(
key: string,
options?: { range?: { offset: number; length: number } },
): Promise<TelemetryObject | null> {
const range = options?.range
try {
const object = await this.client.send(
new GetObjectCommand({
Bucket: this.settings.r2BucketName,
Key: key,
Range: range ? `bytes=${range.offset}-${range.offset + range.length - 1}` : undefined,
}),
)
if (!object.Body) return null
return {
body: object.Body.transformToWebStream(),
httpMetadata: { contentEncoding: object.ContentEncoding },
}
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata
?.httpStatusCode
if (status === 404) return null
throw error
}
}
}
let source: { db: TelemetryDatabase; r2: TelemetryObjectStore } | null | undefined
export function remoteTelemetryDataSource(): {
db: TelemetryDatabase
r2: TelemetryObjectStore
} | null {
if (source !== undefined) return source
const settings = remoteSettings()
source = settings ? { db: new RemoteD1Database(settings), r2: new RemoteR2Store(settings) } : null
return source
}