Files
simstudioai--sim/apps/sim/lib/data-drains/destinations/datadog.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

271 lines
9.1 KiB
TypeScript

import { gzipSync } from 'node:zlib'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { z } from 'zod'
import { parseNdjsonObjects, sleepUntilAborted } from '@/lib/data-drains/destinations/utils'
import type { DeliveryMetadata, DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainDatadogDestination')
const DATADOG_SITES = ['us1', 'us3', 'us5', 'eu1', 'ap1', 'ap2', 'gov'] as const
type DatadogSite = (typeof DATADOG_SITES)[number]
const SITE_HOSTS: Record<DatadogSite, string> = {
us1: 'datadoghq.com',
us3: 'us3.datadoghq.com',
us5: 'us5.datadoghq.com',
eu1: 'datadoghq.eu',
ap1: 'ap1.datadoghq.com',
ap2: 'ap2.datadoghq.com',
gov: 'ddog-gov.com',
}
const MAX_ATTEMPTS = 4
const PER_ATTEMPT_TIMEOUT_MS = 30_000
const MAX_UNCOMPRESSED_BYTES = 5 * 1024 * 1024
const MAX_WIRE_BYTES = 6 * 1024 * 1024
const MAX_ENTRY_BYTES = 1024 * 1024
const MAX_ENTRIES_PER_REQUEST = 1000
const GZIP_THRESHOLD_BYTES = 1024
/**
* Datadog tag format: comma-separated `key:value` pairs. Each key must start
* with a letter and contain only [A-Za-z0-9_:./-]. Validating here so the
* `ddtags` header we emit can't be mangled by user-supplied free-form input.
*/
const DATADOG_TAG_PAIR_RE = /^[A-Za-z][A-Za-z0-9_./-]*:[^,\s][^,]*$/
const datadogConfigSchema = z.object({
site: z.enum(DATADOG_SITES),
service: z.string().min(1).max(100).optional(),
tags: z
.string()
.min(1)
.max(1024)
.refine(
(v) =>
v
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
.every((t) => DATADOG_TAG_PAIR_RE.test(t)),
{ message: 'tags must be comma-separated key:value pairs' }
)
.optional(),
})
const datadogCredentialsSchema = z.object({
apiKey: z.string().min(1, 'apiKey is required'),
})
export type DatadogDestinationConfig = z.infer<typeof datadogConfigSchema>
export type DatadogDestinationCredentials = z.infer<typeof datadogCredentialsSchema>
interface DatadogLogEntry {
ddsource: string
service: string
ddtags: string
message: string
[attribute: string]: unknown
}
function buildEndpoint(site: DatadogSite): string {
return `https://http-intake.logs.${SITE_HOSTS[site]}/api/v2/logs`
}
function buildEntries(
rows: unknown[],
config: DatadogDestinationConfig,
metadata: DeliveryMetadata
): DatadogLogEntry[] {
const ddtags = [
`sim_drain_id:${metadata.drainId}`,
`sim_run_id:${metadata.runId}`,
`sim_source:${metadata.source}`,
...(config.tags ? [config.tags] : []),
].join(',')
const service = config.service ?? 'sim'
return rows.map((row) => {
const attrs = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {}
let message: string
if (typeof row === 'string') {
message = row
} else if (typeof attrs.message === 'string') {
message = attrs.message
} else {
message = JSON.stringify(row)
}
/**
* Spread user attributes first, then force all four reserved fields the
* drain owns: `ddsource`, `service`, `ddtags`, and `message`. Per
* https://docs.datadoghq.com/logs/log_configuration/pipelines/#service-and-source,
* Datadog uses `service` + `ddsource` to pick the processing pipeline, so
* letting a row field clobber them would silently re-route a drain.
*/
return {
...attrs,
ddsource: 'sim',
service,
ddtags,
message,
}
})
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500
}
interface PreparedBody {
body: Uint8Array | string
headers: Record<string, string>
wireBytes: number
rawBytes: number
}
interface PostInput {
url: string
prepared: PreparedBody
signal: AbortSignal
}
function buildRequestBody(payload: string, apiKey: string): PreparedBody {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'DD-API-KEY': apiKey,
Accept: 'application/json',
'User-Agent': 'sim-data-drain/1.0',
}
const rawBytes = Buffer.byteLength(payload, 'utf8')
if (rawBytes > GZIP_THRESHOLD_BYTES) {
const compressed = gzipSync(payload)
headers['Content-Encoding'] = 'gzip'
const view = new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength)
return { body: view, headers, wireBytes: view.byteLength, rawBytes }
}
return { body: payload, headers, wireBytes: rawBytes, rawBytes }
}
async function postWithRetries(input: PostInput): Promise<Response> {
const { body, headers } = input.prepared
let lastError: unknown
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
let retryAfterMs: number | null = null
let response: Response | undefined
try {
response = await fetch(input.url, {
method: 'POST',
// double-cast-allowed: Uint8Array is a valid runtime BodyInit but the DOM lib types only enumerate Blob/FormData/string/etc.
body: body as unknown as BodyInit,
headers,
signal: perAttempt,
})
} catch (error) {
lastError = error
logger.debug('Datadog request failed', { attempt, error: toError(error).message })
}
if (response) {
if (response.ok) {
/** Drain the success body so undici can return the socket to the keep-alive pool. Headers remain readable after consumption. */
await response.text().catch(() => '')
return response
}
if (!isRetryableStatus(response.status)) {
const text = await response.text().catch(() => '')
throw new Error(`Datadog responded with HTTP ${response.status}: ${text}`)
}
lastError = new Error(`Datadog responded with HTTP ${response.status}`)
retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
/** Drain the retryable response body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
}
if (attempt < MAX_ATTEMPTS) {
await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), input.signal)
}
}
throw lastError instanceof Error ? lastError : new Error('Datadog delivery failed after retries')
}
export const datadogDestination: DrainDestination<
DatadogDestinationConfig,
DatadogDestinationCredentials
> = {
type: 'datadog',
displayName: 'Datadog',
configSchema: datadogConfigSchema,
credentialsSchema: datadogCredentialsSchema,
async test({ config, credentials, signal }) {
const probe = [
{
ddsource: 'sim',
service: config.service ?? 'sim',
ddtags: `sim_probe:1${config.tags ? `,${config.tags}` : ''}`,
message: 'sim-data-drain connection test',
},
]
await postWithRetries({
url: buildEndpoint(config.site),
prepared: buildRequestBody(JSON.stringify(probe), credentials.apiKey),
signal,
})
},
openSession({ config, credentials }) {
const url = buildEndpoint(config.site)
return {
async deliver({ body, metadata, signal }) {
const rows = parseNdjsonObjects(body)
const entries = buildEntries(rows, config, metadata)
if (entries.length > MAX_ENTRIES_PER_REQUEST) {
throw new Error(
`Datadog chunk has ${entries.length} entries, exceeds the ${MAX_ENTRIES_PER_REQUEST} per-request limit`
)
}
for (let i = 0; i < entries.length; i++) {
const entryBytes = Buffer.byteLength(JSON.stringify(entries[i]), 'utf8')
if (entryBytes > MAX_ENTRY_BYTES) {
throw new Error(
`Datadog entry at index ${i} is ${entryBytes} bytes, exceeds the ${MAX_ENTRY_BYTES}-byte per-entry limit`
)
}
}
const payload = JSON.stringify(entries)
const prepared = buildRequestBody(payload, credentials.apiKey)
if (prepared.rawBytes > MAX_UNCOMPRESSED_BYTES) {
throw new Error(
`Datadog payload is ${prepared.rawBytes} bytes uncompressed, exceeds the ${MAX_UNCOMPRESSED_BYTES}-byte per-request limit`
)
}
if (prepared.wireBytes > MAX_WIRE_BYTES) {
throw new Error(
`Datadog payload is ${prepared.wireBytes} bytes on the wire, exceeds the ${MAX_WIRE_BYTES}-byte defensive wire-size cap`
)
}
const response = await postWithRetries({
url,
prepared,
signal,
})
const requestId = response.headers.get('dd-request-id') ?? null
logger.debug('Datadog chunk delivered', {
site: config.site,
rows: entries.length,
rawBytes: prepared.rawBytes,
wireBytes: prepared.wireBytes,
})
return {
locator: requestId
? `datadog://${config.site}#${metadata.runId}-${metadata.sequence}@${requestId}`
: `datadog://${config.site}#${metadata.runId}-${metadata.sequence}`,
}
},
async close() {},
}
},
}