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,378 @@
import { Hono } from 'hono'
import { redact, truncateUtf8 } from './redact'
import { batchSchema, type TelemetryBatch } from './schema'
const MAX_REQUEST_BYTES = 64 * 1024
const HARD_MAX_BATCHES_PER_INSTALLATION_PER_DAY = 25
const HARD_MAX_ACCEPTED_BATCHES_PER_DAY = 100_000
export interface Bindings {
DB: D1Database
INSTALLATION_HMAC_SECRET: string
INGEST_ENABLED?: string
MAX_BATCHES_PER_INSTALLATION_PER_DAY?: string
MAX_ACCEPTED_BATCHES_PER_DAY?: string
}
type Variables = { requestId: string }
type IngestionReservation = 'reserved' | 'duplicate' | 'installation_limit' | 'global_limit'
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
app.use('*', async (context, next) => {
context.set('requestId', crypto.randomUUID())
await next()
context.header('Cache-Control', 'no-store')
})
app.get('/health', (context) =>
context.json({ status: 'ok', schema_version: 1 }, 200, { 'Cache-Control': 'no-store' }),
)
app.post('/v1/batch', async (context) => {
const contentLength = Number(context.req.header('content-length') ?? '0')
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return context.json({ error: 'request_too_large' }, 413)
}
const raw = await context.req.arrayBuffer()
if (raw.byteLength > MAX_REQUEST_BYTES) return context.json({ error: 'request_too_large' }, 413)
let input: unknown
try {
input = JSON.parse(new TextDecoder().decode(raw))
} catch {
return context.json({ error: 'invalid_json' }, 400)
}
const parsed = batchSchema.safeParse(input)
if (!parsed.success) {
// Do not accept or log legacy error payloads after error reporting was removed.
if (containsLegacyErrorEvent(input))
return context.json({ error: 'error_reporting_disabled' }, 400)
return context.json(
{
error: 'invalid_batch',
issues: parsed.error.issues.map((issue) => ({
path: issue.path.join('.'),
code: issue.code,
})),
},
400,
)
}
if (context.env.INGEST_ENABLED === 'false') {
return context.json({ error: 'ingest_disabled' }, 503, { 'Retry-After': '60' })
}
if (!context.env.INSTALLATION_HMAC_SECRET || context.env.INSTALLATION_HMAC_SECRET.length < 32) {
return context.json({ error: 'service_unavailable' }, 503)
}
try {
const accepted = await context.env.DB.prepare(
'SELECT 1 FROM accepted_batches WHERE batch_id = ? LIMIT 1',
)
.bind(parsed.data.batch_id)
.first()
if (accepted) return context.json({ accepted: true, duplicate: true })
const installationHash = await hmacInstallationId(
context.env.INSTALLATION_HMAC_SECRET,
parsed.data.installation_id,
)
const limits = ingestionLimits(context.env)
const reservation = await reserveIngestion(
context.env.DB,
parsed.data.batch_id,
installationHash,
limits,
)
if (reservation === 'duplicate') return context.json({ accepted: true, duplicate: true })
if (reservation === 'installation_limit') {
return context.json({ error: 'installation_batch_limit' }, 429, {
'Retry-After': retryAfterSeconds().toString(),
})
}
if (reservation === 'global_limit') {
console.error('Telemetry ingestion budget exhausted', { limit: limits.global })
return context.json({ error: 'global_batch_limit' }, 429, {
'Retry-After': retryAfterSeconds().toString(),
})
}
try {
await persistBatch(context.env.DB, sanitizeBatch(parsed.data), installationHash)
} catch (error) {
await rollbackIngestion(context.env.DB, parsed.data.batch_id, installationHash)
throw error
}
return context.json({ accepted: true, duplicate: false })
} catch (error) {
console.error('Telemetry ingestion failed', {
requestId: context.get('requestId'),
error: error instanceof Error ? error.message : String(error),
})
return context.json({ error: 'temporarily_unavailable' }, 503)
}
})
app.notFound((context) => context.json({ error: 'not_found' }, 404))
app.onError((error, context) => {
console.error('Unhandled telemetry worker error', {
requestId: context.get('requestId'),
error: error.message,
})
return context.json({ error: 'temporarily_unavailable' }, 503)
})
function containsLegacyErrorEvent(input: unknown): boolean {
if (!input || typeof input !== 'object') return false
const events = (input as { events?: unknown }).events
return (
Array.isArray(events) &&
events.some(
(event) =>
Boolean(event) &&
typeof event === 'object' &&
(event as { type?: unknown }).type === 'error',
)
)
}
async function hmacInstallationId(secret: string, installationId: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(installationId))
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}
function ingestionLimits(env: Bindings): { installation: number; global: number } {
return {
installation: Math.min(
positiveInteger(
env.MAX_BATCHES_PER_INSTALLATION_PER_DAY,
HARD_MAX_BATCHES_PER_INSTALLATION_PER_DAY,
),
HARD_MAX_BATCHES_PER_INSTALLATION_PER_DAY,
),
global: Math.min(
positiveInteger(env.MAX_ACCEPTED_BATCHES_PER_DAY, HARD_MAX_ACCEPTED_BATCHES_PER_DAY),
HARD_MAX_ACCEPTED_BATCHES_PER_DAY,
),
}
}
async function reserveIngestion(
db: D1Database,
batchId: string,
installationHash: string,
limits: { installation: number; global: number },
): Promise<IngestionReservation> {
const day = utcDay()
const batch = await db
.prepare(
'INSERT OR IGNORE INTO accepted_batches (batch_id, installation_hash, accepted_at) VALUES (?, ?, unixepoch())',
)
.bind(batchId, installationHash)
.run()
if (batch.meta.changes !== 1) return 'duplicate'
const installation = await db
.prepare(
`INSERT INTO ingestion_daily (day, installation_hash, accepted_batches)
VALUES (?, ?, 1)
ON CONFLICT (day, installation_hash) DO UPDATE
SET accepted_batches = accepted_batches + 1
WHERE accepted_batches < ?`,
)
.bind(day, installationHash, limits.installation)
.run()
if (installation.meta.changes !== 1) {
await db.prepare('DELETE FROM accepted_batches WHERE batch_id = ?').bind(batchId).run()
return 'installation_limit'
}
const global = await db
.prepare(
`INSERT INTO ingestion_global_daily (day, accepted_batches)
VALUES (?, 1)
ON CONFLICT (day) DO UPDATE
SET accepted_batches = accepted_batches + 1
WHERE accepted_batches < ?`,
)
.bind(day, limits.global)
.run()
if (global.meta.changes !== 1) {
await db.batch([
db
.prepare(
'UPDATE ingestion_daily SET accepted_batches = accepted_batches - 1 WHERE day = ? AND installation_hash = ?',
)
.bind(day, installationHash),
db.prepare('DELETE FROM accepted_batches WHERE batch_id = ?').bind(batchId),
])
return 'global_limit'
}
const currentGlobal = await db
.prepare('SELECT accepted_batches FROM ingestion_global_daily WHERE day = ?')
.bind(day)
.first<{ accepted_batches: number }>()
if (currentGlobal) warnIngestionThresholds(currentGlobal.accepted_batches, limits.global)
return 'reserved'
}
async function rollbackIngestion(
db: D1Database,
batchId: string,
installationHash: string,
): Promise<void> {
const day = utcDay()
await db.batch([
db
.prepare('DELETE FROM accepted_batches WHERE batch_id = ? AND installation_hash = ?')
.bind(batchId, installationHash),
db
.prepare(
'UPDATE ingestion_daily SET accepted_batches = accepted_batches - 1 WHERE day = ? AND installation_hash = ?',
)
.bind(day, installationHash),
db
.prepare(
'UPDATE ingestion_global_daily SET accepted_batches = accepted_batches - 1 WHERE day = ?',
)
.bind(day),
])
}
function sanitizeBatch(batch: TelemetryBatch): TelemetryBatch {
return {
...batch,
app: {
...batch.app,
version: truncateUtf8(redact(batch.app.version), 64),
platform: truncateUtf8(redact(batch.app.platform), 32),
arch: truncateUtf8(redact(batch.app.arch), 32),
},
events: batch.events,
}
}
async function persistBatch(
db: D1Database,
batch: TelemetryBatch,
installationHash: string,
): Promise<void> {
const acceptedDay = utcDay()
const statements: D1PreparedStatement[] = [
db
.prepare(
`INSERT OR IGNORE INTO installations (
installation_hash, first_seen_at, last_seen_at,
first_seen_day, app_version, platform, arch
) VALUES (?, unixepoch(), unixepoch(), ?, ?, ?, ?)`,
)
.bind(installationHash, acceptedDay, batch.app.version, batch.app.platform, batch.app.arch),
db.prepare('INSERT OR IGNORE INTO platforms (platform) VALUES (?)').bind(batch.app.platform),
]
for (const event of batch.events) {
statements.push(
db
.prepare(
'INSERT OR IGNORE INTO daily_active (day, installation_hash, app_version, platform, arch) VALUES (?, ?, ?, ?, ?)',
)
.bind(event.day, installationHash, batch.app.version, batch.app.platform, batch.app.arch),
)
}
await db.batch(statements)
}
async function runMaintenance(db: D1Database): Promise<void> {
const now = new Date()
const yesterday = daysAgo(now, 1)
const dayMinus = (days: number) => daysAgo(now, days)
// Usage-only maintenance. Error aggregate/R2 work was intentionally removed.
await db.batch([
db
.prepare(
`INSERT INTO daily_totals (day, new_installations, active_installations)
VALUES (?,
(SELECT COUNT(*) FROM installations WHERE first_seen_day = ?),
(SELECT COUNT(*) FROM daily_active WHERE day = ?))
ON CONFLICT (day) DO UPDATE SET
new_installations = excluded.new_installations,
active_installations = excluded.active_installations`,
)
.bind(yesterday, yesterday, yesterday),
db
.prepare(
`DELETE FROM wau_seen WHERE NOT EXISTS (
SELECT 1 FROM daily_active da
WHERE da.installation_hash = wau_seen.installation_hash AND da.day >= ?
)`,
)
.bind(dayMinus(6)),
db
.prepare(
`DELETE FROM mau_seen WHERE NOT EXISTS (
SELECT 1 FROM daily_active da
WHERE da.installation_hash = mau_seen.installation_hash AND da.day >= ?
)`,
)
.bind(dayMinus(29)),
db.prepare("DELETE FROM daily_active WHERE day < date('now', '-35 days')"),
db.prepare("DELETE FROM accepted_batches WHERE accepted_at < unixepoch('now', '-8 days')"),
db.prepare("DELETE FROM ingestion_daily WHERE day < date('now', '-8 days')"),
db.prepare("DELETE FROM ingestion_global_daily WHERE day < date('now', '-8 days')"),
db.prepare("DELETE FROM daily_active_dims WHERE day < date('now', '-365 days')"),
])
}
function daysAgo(now: Date, days: number): string {
const date = new Date(now)
date.setUTCDate(date.getUTCDate() - days)
return date.toISOString().slice(0, 10)
}
function positiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value)
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10)
}
function warnIngestionThresholds(count: number, limit: number): void {
for (const ratio of [0.8, 0.9, 0.95]) {
if (count === Math.ceil(limit * ratio)) {
console.warn('Telemetry ingestion budget threshold reached', {
count,
limit,
percent: ratio * 100,
})
}
}
}
function retryAfterSeconds(now = new Date()): number {
const nextDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)
return Math.max(1, Math.ceil((nextDay - now.getTime()) / 1_000))
}
export { app, hmacInstallationId, runMaintenance, sanitizeBatch }
export default {
fetch: app.fetch,
async scheduled(_controller: ScheduledController, env: Bindings, context: ExecutionContext) {
context.waitUntil(runMaintenance(env.DB))
},
} satisfies ExportedHandler<Bindings>

View File

@ -0,0 +1,35 @@
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const replacements: Array<[RegExp, string]> = [
[/\bbearer\s+[a-z0-9._~+/=-]+/gi, 'Bearer <redacted>'],
[
/\b(authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|token)\b\s*[:=]\s*[^\s,;]+/gi,
'$1=<redacted>',
],
[
/([?&](?:token|access_token|refresh_token|api_key|key|code|secret|session|signature)=)[^&#\s]+/gi,
'$1<redacted>',
],
[/\b[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}\b/gi, '<email>'],
[/\b[a-z]:\\users\\[^\\/\s]+/gi, '<home>'],
[/(?:\/home|\/users)\/[^/\s]+\//gi, '<home>/'],
[/\b[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\b/gi, '<uuid>'],
]
export function redact(input: string): string {
return replacements.reduce(
(value, [pattern, replacement]) => value.replace(pattern, replacement),
input.replaceAll('\0', ''),
)
}
export function truncateUtf8(input: string, maxBytes: number): string {
const bytes = encoder.encode(input)
if (bytes.byteLength <= maxBytes) return input
return decoder.decode(bytes.slice(0, maxBytes)).replace(/\uFFFD$/, '')
}
export function byteLength(input: string): number {
return encoder.encode(input).byteLength
}

View File

@ -0,0 +1,34 @@
import { z } from 'zod'
const uuid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const timestamp = z.string().datetime({ offset: true })
export const heartbeatEventSchema = z
.object({
type: z.literal('heartbeat'),
event_id: uuid,
occurred_at: timestamp,
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
.strict()
export const telemetryEventSchema = heartbeatEventSchema
export const batchSchema = z
.object({
schema_version: z.literal(1),
batch_id: uuid,
installation_id: uuid,
app: z
.object({
version: z.string().min(1).max(64),
environment: z.enum(['production', 'development']),
platform: z.string().min(1).max(32),
arch: z.string().min(1).max(32),
})
.strict(),
events: z.array(telemetryEventSchema).min(1).max(10),
})
.strict()
export type TelemetryBatch = z.infer<typeof batchSchema>