Add metrics to redirect service
This commit is contained in:
@@ -15,6 +15,7 @@ export type ServiceConfig = {
|
|||||||
safelinkPdsUrl?: string
|
safelinkPdsUrl?: string
|
||||||
safelinkAgentIdentifier?: string
|
safelinkAgentIdentifier?: string
|
||||||
safelinkAgentPass?: string
|
safelinkAgentPass?: string
|
||||||
|
metricsApiHost?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DbConfig = {
|
export type DbConfig = {
|
||||||
@@ -45,6 +46,7 @@ export type Environment = {
|
|||||||
safelinkPdsUrl?: string
|
safelinkPdsUrl?: string
|
||||||
safelinkAgentIdentifier?: string
|
safelinkAgentIdentifier?: string
|
||||||
safelinkAgentPass?: string
|
safelinkAgentPass?: string
|
||||||
|
metricsApiHost?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const readEnv = (): Environment => {
|
export const readEnv = (): Environment => {
|
||||||
@@ -65,6 +67,7 @@ export const readEnv = (): Environment => {
|
|||||||
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
|
safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'),
|
||||||
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
|
safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'),
|
||||||
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
|
safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'),
|
||||||
|
metricsApiHost: envStr('LINK_METRICS_API_HOST'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ export const envToCfg = (env: Environment): Config => {
|
|||||||
safelinkPdsUrl: env.safelinkPdsUrl,
|
safelinkPdsUrl: env.safelinkPdsUrl,
|
||||||
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
|
safelinkAgentIdentifier: env.safelinkAgentIdentifier,
|
||||||
safelinkAgentPass: env.safelinkAgentPass,
|
safelinkAgentPass: env.safelinkAgentPass,
|
||||||
|
metricsApiHost: env.metricsApiHost,
|
||||||
}
|
}
|
||||||
if (!env.dbPostgresUrl) {
|
if (!env.dbPostgresUrl) {
|
||||||
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {SafelinkClient} from './cache/safelinkClient.js'
|
import {SafelinkClient} from './cache/safelinkClient.js'
|
||||||
import {type Config} from './config.js'
|
import {type Config} from './config.js'
|
||||||
import Database from './db/index.js'
|
import Database from './db/index.js'
|
||||||
|
import {MetricsClient} from './metrics.js'
|
||||||
|
|
||||||
export type AppContextOptions = {
|
export type AppContextOptions = {
|
||||||
cfg: Config
|
cfg: Config
|
||||||
@@ -12,6 +13,7 @@ export class AppContext {
|
|||||||
db: Database
|
db: Database
|
||||||
safelinkClient: SafelinkClient
|
safelinkClient: SafelinkClient
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
|
metrics: MetricsClient
|
||||||
|
|
||||||
constructor(private opts: AppContextOptions) {
|
constructor(private opts: AppContextOptions) {
|
||||||
this.cfg = this.opts.cfg
|
this.cfg = this.opts.cfg
|
||||||
@@ -20,6 +22,9 @@ export class AppContext {
|
|||||||
cfg: this.opts.cfg.service,
|
cfg: this.opts.cfg.service,
|
||||||
db: this.opts.db,
|
db: this.opts.db,
|
||||||
})
|
})
|
||||||
|
this.metrics = new MetricsClient({
|
||||||
|
trackingEndpoint: this.opts.cfg.service.metricsApiHost,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class LinkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
this.ctx.metrics.start()
|
||||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||||
this.server.keepAliveTimeout = 90000
|
this.server.keepAliveTimeout = 90000
|
||||||
this.terminator = createHttpTerminator({server: this.server})
|
this.terminator = createHttpTerminator({server: this.server})
|
||||||
@@ -46,5 +47,6 @@ export class LinkService {
|
|||||||
this.ctx.abortController.abort()
|
this.ctx.abortController.abort()
|
||||||
await this.terminator?.terminate()
|
await this.terminator?.terminate()
|
||||||
await this.ctx.db.close()
|
await this.ctx.db.close()
|
||||||
|
this.ctx.metrics.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import {httpLogger} from './logger.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New metrics events should be added here
|
||||||
|
*/
|
||||||
|
type Events = {
|
||||||
|
redirect: {
|
||||||
|
link: string
|
||||||
|
whitelisted: 'unknown' | 'yes'
|
||||||
|
blocked: boolean
|
||||||
|
warned: boolean
|
||||||
|
}
|
||||||
|
invalid_redirect: {
|
||||||
|
link: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event<M extends Record<string, any>> = {
|
||||||
|
time: number
|
||||||
|
event: keyof M
|
||||||
|
payload: M[keyof M]
|
||||||
|
metadata: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
trackingEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This MetricsClient is duplicated from both `social-app` and `atproto`
|
||||||
|
* codebases.
|
||||||
|
*/
|
||||||
|
export class MetricsClient<M extends Record<string, any> = Events> {
|
||||||
|
maxBatchSize = 100
|
||||||
|
|
||||||
|
private disabled: boolean = false
|
||||||
|
private started: boolean = false
|
||||||
|
private queue: Event<M>[] = []
|
||||||
|
private flushInterval: NodeJS.Timeout | null = null
|
||||||
|
constructor(private config: Config) {
|
||||||
|
this.disabled = !config.trackingEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
this.flushInterval = setInterval(() => {
|
||||||
|
this.flush()
|
||||||
|
}, 10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.flushInterval) {
|
||||||
|
clearInterval(this.flushInterval)
|
||||||
|
this.flushInterval = null
|
||||||
|
}
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
track<E extends keyof M>(event: E, payload: M[E]) {
|
||||||
|
if (this.disabled) return
|
||||||
|
|
||||||
|
this.start()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deviceId is required for sharding events in Middleman. To avoid a hot
|
||||||
|
* shard, we generate a random anonymous IDs for this client.
|
||||||
|
*
|
||||||
|
* @see https://github.com/bluesky-social/tango/blob/d5819cde419d13e0d2cf837f4b30d48529d64060/middleman/handlers_tracking.go#L195
|
||||||
|
*/
|
||||||
|
const anonId = `anon-${crypto.randomUUID()}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event structure is like this to ensure compat with Middleman, which
|
||||||
|
* receives events like this from other codebases, including `social-app`.
|
||||||
|
*/
|
||||||
|
const e = {
|
||||||
|
source: 'blink',
|
||||||
|
time: Date.now(),
|
||||||
|
event,
|
||||||
|
payload,
|
||||||
|
metadata: {
|
||||||
|
base: {
|
||||||
|
deviceId: anonId,
|
||||||
|
sessionId: anonId,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
did: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.queue.push(e)
|
||||||
|
|
||||||
|
if (this.queue.length > this.maxBatchSize) {
|
||||||
|
this.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (this.disabled) return
|
||||||
|
if (!this.queue.length) return
|
||||||
|
const events = this.queue.splice(0, this.queue.length)
|
||||||
|
this.sendBatch(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendBatch(events: Event<M>[]) {
|
||||||
|
if (this.disabled || !this.config.trackingEndpoint) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.config.trackingEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({events}),
|
||||||
|
keepalive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => 'Unknown error')
|
||||||
|
httpLogger.error(
|
||||||
|
{err: new Error(`${res.status} Failed to fetch - ${errorText}`)},
|
||||||
|
'Failed to send metrics',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Drain response body to allow connection reuse.
|
||||||
|
await res.text().catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
httpLogger.error({err}, 'Failed to send metrics')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
url.pathname === '/redirect') || // is a redirect loop
|
url.pathname === '/redirect') || // is a redirect loop
|
||||||
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
|
INTERNAL_IP_REGEX.test(url.hostname) // isn't directing to an internal location
|
||||||
) {
|
) {
|
||||||
|
ctx.metrics.track('invalid_redirect', {link})
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
|
||||||
return res.status(302).end()
|
return res.status(302).end()
|
||||||
@@ -48,6 +49,9 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
res.type('html')
|
res.type('html')
|
||||||
|
|
||||||
let html: string | undefined
|
let html: string | undefined
|
||||||
|
let whitelisted: 'unknown' | 'yes' = 'unknown'
|
||||||
|
let blocked: boolean = false
|
||||||
|
let warned: boolean = false
|
||||||
|
|
||||||
if (ctx.cfg.service.safelinkEnabled) {
|
if (ctx.cfg.service.safelinkEnabled) {
|
||||||
const rule = await ctx.safelinkClient.tryFindRule(link)
|
const rule = await ctx.safelinkClient.tryFindRule(link)
|
||||||
@@ -55,6 +59,7 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
switch (rule.action) {
|
switch (rule.action) {
|
||||||
case 'whitelist':
|
case 'whitelist':
|
||||||
redirectLogger.info({rule}, 'Whitelist rule matched')
|
redirectLogger.info({rule}, 'Whitelist rule matched')
|
||||||
|
whitelisted = 'yes'
|
||||||
break
|
break
|
||||||
case 'block':
|
case 'block':
|
||||||
html = linkWarningLayout(
|
html = linkWarningLayout(
|
||||||
@@ -66,6 +71,7 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
)
|
)
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
redirectLogger.info({rule}, 'Block rule matched')
|
redirectLogger.info({rule}, 'Block rule matched')
|
||||||
|
blocked = true
|
||||||
break
|
break
|
||||||
case 'warn':
|
case 'warn':
|
||||||
html = linkWarningLayout(
|
html = linkWarningLayout(
|
||||||
@@ -77,6 +83,7 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
)
|
)
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
redirectLogger.info({rule}, 'Warn rule matched')
|
redirectLogger.info({rule}, 'Warn rule matched')
|
||||||
|
warned = true
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
redirectLogger.warn({rule}, 'Unknown rule matched')
|
redirectLogger.warn({rule}, 'Unknown rule matched')
|
||||||
@@ -89,6 +96,13 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
html = linkRedirectContents(url.href)
|
html = linkRedirectContents(url.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.metrics.track('redirect', {
|
||||||
|
link,
|
||||||
|
whitelisted,
|
||||||
|
blocked,
|
||||||
|
warned,
|
||||||
|
})
|
||||||
|
|
||||||
return res.end(html)
|
return res.end(html)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user