Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-logs-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': minor
---

feat(node): add a logs API, `posthog.logger.info(...)` and `posthog.captureLog(...)`, configured with the `logs` option. Records attach the active span's trace and span ids and the `withContext` distinctId and sessionId, and `serviceName` defaults to the traces or metrics one.
7 changes: 7 additions & 0 deletions .changeset/span-ids-getters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@posthog/core': minor
'@posthog/types': minor
'posthog-node': minor
---

feat(node): expose `traceId` and `spanId` getters on the `Span` handle, for correlating logs from other loggers with a trace without parsing `traceparent()`
3 changes: 3 additions & 0 deletions packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"_attachThemeStyle",
"_autoSubmitPrefilledResponses",
"_automaticDisplayDispose",
"_backgroundFlushPromise",
"_baseEventProperties",
"_beforeUnloadListener",
"_beginCookieSyncSuppression",
Expand Down Expand Up @@ -197,6 +198,7 @@
"_detachWidgetSelectorListener",
"_determineInitialView",
"_disabled",
"_discardQueueIfConsentWithdrawn",
"_discardRecording",
"_displayOptions",
"_dispose",
Expand Down Expand Up @@ -422,6 +424,7 @@
"_isBelowMinimumDuration",
"_isCacheStale",
"_isConsentTransition",
"_isConsentWithdrawn",
"_isConsoleLogCaptureEnabled",
"_isConversationsEnabled",
"_isCurrentTicketResolved",
Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/logs/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2300,3 +2300,122 @@ describe('PostHogLogs', () => {
})
})
})

describe('PostHogLogs consent and shared background flush', () => {
const create = (instance: any, logger: Logger = createMockLogger(), config = resolveForTest()): PostHogLogs =>
new PostHogLogs(
instance,
config,
logger,
getContextFor(instance),
immediateOnReady,
undefined,
undefined,
() => instance.consentWithdrawn === true
)

it('discards queued records instead of exporting them after opt-out', async () => {
const instance = createMockInstance()
const logs = create(instance)
logs.captureLog({ body: 'before opt-out' })

instance.consentWithdrawn = true
await logs.flush()

expect(instance._sendLogsBatch).not.toHaveBeenCalled()
expect(readQueue(instance)).toHaveLength(0)

// Opting back in does not resurrect what was captured before the withdrawal.
instance.consentWithdrawn = false
await logs.flush()
expect(instance._sendLogsBatch).not.toHaveBeenCalled()
})

it('stops a drain under way when the user opts out between batches', async () => {
let releaseFirst: () => void = () => {}
const instance = createMockInstance({
_sendLogsBatch: vi.fn(
() =>
new Promise((resolve) => {
releaseFirst = () => resolve({ kind: 'ok' })
})
),
})
const logs = create(instance, createMockLogger(), resolveForTest({ maxBatchRecordsPerPost: 1 }))
logs.captureLog({ body: 'first' })
logs.captureLog({ body: 'second' })

const flushing = logs.flush()
await Promise.resolve()
instance.consentWithdrawn = true
releaseFirst()
await flushing

expect(instance._sendLogsBatch).toHaveBeenCalledTimes(1)
expect(readQueue(instance)).toHaveLength(0)
})

it('does not retry a 413-rejected batch after the user opts out', async () => {
let rejectFirst: () => void = () => {}
const instance = createMockInstance({
_sendLogsBatch: vi.fn(
() =>
new Promise((resolve) => {
rejectFirst = () => resolve({ kind: 'too-large' })
})
),
})
const logs = create(instance, createMockLogger(), resolveForTest({ maxBatchRecordsPerPost: 4 }))
logs.captureLog({ body: 'a' })
logs.captureLog({ body: 'b' })

const flushing = logs.flush()
await Promise.resolve()
instance.consentWithdrawn = true
rejectFirst()
await flushing

expect(instance._sendLogsBatch).toHaveBeenCalledTimes(1)
expect(readQueue(instance)).toHaveLength(0)
})

it('shares one background wrapper across overlapping callers', async () => {
let failSend: () => void = () => {}
const instance = createMockInstance({
_sendLogsBatch: vi.fn(
() =>
new Promise((resolve) => {
failSend = () => resolve({ kind: 'retry-later', error: new Error('down') })
})
),
})
const logger = createMockLogger()
const logs = create(instance, logger)
logs.captureLog({ body: 'slow' })

const first = logs.flushInBackground()
const second = logs.flushInBackground()
expect(second).toBe(first)
await Promise.resolve()
failSend()
await Promise.all([first, second])

expect(instance._sendLogsBatch).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledTimes(1)
})

it('keeps flushing a host whose optedOut is only a shutdown gate when no consent callback is given', async () => {
const instance = createMockInstance({ optedOut: false })
const logs = new PostHogLogs(
instance,
resolveForTest(),
createMockLogger(),
getContextFor(instance),
immediateOnReady
)
logs.captureLog({ body: 'final flush' })
instance.optedOut = true
await logs.flush()
expect(instance._sendLogsBatch).toHaveBeenCalledTimes(1)
})
})
60 changes: 57 additions & 3 deletions packages/core/src/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export class PostHogLogs {
// A batch captures this when it is assembled, so it can tell that the records it is
// holding no longer correspond to anything queued.
private _queueGeneration = 0
// The `flushInBackground()` wrapper for the flush in flight, shared so overlapping
// callers count and log one failed POST once rather than once per caller.
private _backgroundFlushPromise: Promise<void> | null = null
// Every path that can start a send checks this, not just the retry timer.
private _retryAfter = new RetryAfterWindow()
// Consecutive failed flushes; drives exponential backoff on the retry timer.
Expand Down Expand Up @@ -61,7 +64,12 @@ export class PostHogLogs {
// OTLP instrumentation scope name. Overrides the `getLibraryId()` fallback
// so a host can label a specific capture stream (e.g. 'console' for browser
// console auto-capture). Defaults to `getLibraryId()` when omitted.
private readonly _scopeName?: string
private readonly _scopeName?: string,
// True once the user has withdrawn consent. Checked before every flush iteration so
// queued records are dropped rather than exported, by a timer or a drain under way.
// Opt-in: a browser host's `optedOut` also reads true while it shuts down, when the
// final flush must still go out, so only a host that separates the two passes this.
private readonly _isConsentWithdrawn: () => boolean = () => false
) {
this._maxBufferSize = _config.maxBufferSize
// Never below the flush trigger: a smaller eviction cap would stop the
Expand All @@ -83,6 +91,11 @@ export class PostHogLogs {
this._instance.setPersistedProperty(PostHogPersistedProperty.LogsQueue, [])
}

/** True while the endpoint's `Retry-After` window is open, so a host's automatic flush can skip logs. */
get throttled(): boolean {
return this._retryAfter.isOpen()
}

/**
* Clears the flush timer and rate-cap state. The host owns the record queue
* and clears it separately (the browser empties its in-memory store).
Expand Down Expand Up @@ -269,6 +282,9 @@ export class PostHogLogs {
private async _flushInner(): Promise<void> {
this._flushTimer.clear()

if (this._discardQueueIfConsentWithdrawn()) {
return
}
let queue = this._instance.getPersistedProperty<BufferedLogEntry[]>(PostHogPersistedProperty.LogsQueue) ?? []
if (queue.length === 0) {
return
Expand All @@ -278,6 +294,11 @@ export class PostHogLogs {
let sentCount = 0

while (queue.length > 0 && sentCount < originalQueueLength) {
// Re-checked on every iteration, including a 413 shrink-and-retry that sends no
// progress: a send suspends, so the user can opt out while one is in flight.
if (this._discardQueueIfConsentWithdrawn()) {
return
}
// Captured per batch, not per flush: a clear that lands between batches leaves
// the next one assembled from an already-fresh queue, so that batch advances
// normally instead of being retired for a purge it never held.
Expand Down Expand Up @@ -360,6 +381,21 @@ export class PostHogLogs {
}
}

/**
* Drops the queue once consent is withdrawn. Records carry the distinct id and
* session id, so whatever is still queued when the user opts out must not be
* exported, by a pending timer or by a drain already under way.
*/
private _discardQueueIfConsentWithdrawn(): boolean {
if (!this._isConsentWithdrawn()) {
return false
}
if (this._hasQueuedRecords()) {
this.clearQueue()
}
return true
}

private async _persistQueueAdvance(consumed: number): Promise<void> {
// Re-read in case captures landed mid-flush. Subtract head records the FIFO cap
// evicted during the send (already-sent records that left the queue), so we drop
Expand Down Expand Up @@ -481,8 +517,17 @@ export class PostHogLogs {
})
}

private _flushInBackground(): void {
void this.flush()
/**
* `flush()` for a host that drives logs from its own flush cycle. Never rejects, and when
* records remain (a failed send, or captures that landed mid-flush) re-arms the timer with
* backoff, as the pipeline's own timer flush does. A bare `flush()` clears the timer and
* leaves nothing to retry the records once traffic stops.
*/
flushInBackground(): Promise<void> {
if (this._backgroundFlushPromise) {
return this._backgroundFlushPromise
}
const promise = this.flush()
.then(
() => {
this._consecutiveFlushFailures = 0
Expand All @@ -498,9 +543,18 @@ export class PostHogLogs {
// Records remaining (transient failure or mid-flush captures) would otherwise
// sit undelivered on a quiet page; re-arm so the timer retries them, backing
// off on consecutive failures.
if (this._backgroundFlushPromise === promise) {
this._backgroundFlushPromise = null
}
if (!this._instance.isDisabled && this._hasQueuedRecords()) {
this._flushTimer.armNoEarlierThan(this._nextFlushDelay())
}
})
this._backgroundFlushPromise = promise
return promise
}

private _flushInBackground(): void {
void this.flushInBackground()
}
}
Loading
Loading