Skip to content

Commit 8395461

Browse files
✨ enable rum profiler compression when possible (#3861)
1 parent 3ccc3a5 commit 8395461

File tree

17 files changed

+310
-158
lines changed

17 files changed

+310
-158
lines changed

packages/core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"url": "https://github.com/DataDog/browser-sdk.git",
1919
"directory": "packages/core"
2020
},
21+
"devDependencies": {
22+
"@types/pako": "2.0.4",
23+
"pako": "2.1.0"
24+
},
2125
"volta": {
2226
"extends": "../../package.json"
2327
},

packages/core/src/domain/deflate/deflate.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ export const enum DeflateEncoderStreamId {
5353
REPLAY = 1,
5454
RUM = 2,
5555
TELEMETRY = 4,
56+
PROFILING = 6,
5657
}

packages/core/test/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export * from './wait'
2929
export * from './consoleLog'
3030
export * from './createHooks'
3131
export * from './fakeSessionStoreStrategy'
32+
export * from './readFormData'

packages/core/test/interceptRequests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { EndpointBuilder } from '../src'
22
import { INTAKE_URL_PARAMETERS, noop } from '../src'
33
import { mockXhr, MockXhr } from './emulate/mockXhr'
4+
import { readFormData } from './readFormData'
45
import { registerCleanupTask } from './registerCleanupTask'
56

67
const INTAKE_PARAMS = INTAKE_URL_PARAMETERS.join('&')
@@ -107,3 +108,6 @@ export function interceptRequests() {
107108
},
108109
}
109110
}
111+
export function readFormDataRequest<T>(request: Request): Promise<T> {
112+
return readFormData(request.body as unknown as FormData)
113+
}

packages/core/test/readFormData.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { inflate } from 'pako'
2+
3+
// Zlib streams using a default compression are starting with bytes 120 156 (0x78 0x9c)
4+
// https://stackoverflow.com/a/9050274
5+
const Z_LIB_MAGIC_BYTES = 0x789c
6+
7+
export async function readFormData<T>(formData: FormData): Promise<T> {
8+
const entries = getEntries(formData)
9+
const data: Record<string, unknown> = {}
10+
11+
for (const [key, value] of Object.entries(entries)) {
12+
if (value instanceof Blob) {
13+
data[key] = await readJsonBlob(value)
14+
} else {
15+
data[key] = value
16+
}
17+
}
18+
19+
return data as T
20+
}
21+
22+
function getEntries(payload: FormData) {
23+
const entries = {} as Record<string, FormDataEntryValue>
24+
payload.forEach((data, key) => {
25+
entries[key] = data
26+
})
27+
return entries
28+
}
29+
30+
function isZlibCompressed(buffer: ArrayBuffer) {
31+
return new DataView(buffer).getUint16(0) === Z_LIB_MAGIC_BYTES
32+
}
33+
34+
function readJsonBlob<T>(blob: Blob): Promise<T> {
35+
// Safari Mobile 14 should support blob.text() or blob.arrayBuffer() but the APIs are not defined on the safari
36+
// provided by browserstack, so we still need to use a FileReader for now.
37+
// https://caniuse.com/mdn-api_blob_arraybuffer
38+
// https://caniuse.com/mdn-api_blob_text
39+
return new Promise((resolve) => {
40+
const reader = new FileReader()
41+
reader.addEventListener('loadend', () => {
42+
const buffer = reader.result as ArrayBuffer
43+
const decompressed = isZlibCompressed(buffer) ? inflate(buffer) : buffer
44+
const decoded = new TextDecoder().decode(decompressed)
45+
const deserialized = JSON.parse(decoded)
46+
47+
resolve(deserialized as T)
48+
})
49+
reader.readAsArrayBuffer(blob)
50+
})
51+
}

packages/rum-core/src/boot/rumPublicApi.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
Account,
1414
RumInternalContext,
1515
Telemetry,
16+
Encoder,
1617
} from '@datadog/browser-core'
1718
import {
1819
ContextManagerMethod,
@@ -484,7 +485,8 @@ export interface ProfilerApi {
484485
hooks: Hooks,
485486
configuration: RumConfiguration,
486487
sessionManager: RumSessionManager,
487-
viewHistory: ViewHistory
488+
viewHistory: ViewHistory,
489+
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder
488490
) => void
489491
}
490492

@@ -544,14 +546,17 @@ export function makeRumPublicApi(
544546
trackingConsentState,
545547
customVitalsState,
546548
(configuration, deflateWorker, initialViewOptions) => {
549+
const createEncoder =
550+
deflateWorker && options.createDeflateEncoder
551+
? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId)
552+
: createIdentityEncoder
553+
547554
const startRumResult = startRumImpl(
548555
configuration,
549556
recorderApi,
550557
profilerApi,
551558
initialViewOptions,
552-
deflateWorker && options.createDeflateEncoder
553-
? (streamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId)
554-
: createIdentityEncoder,
559+
createEncoder,
555560
trackingConsentState,
556561
customVitalsState,
557562
bufferedDataObservable,
@@ -572,7 +577,8 @@ export function makeRumPublicApi(
572577
startRumResult.hooks,
573578
configuration,
574579
startRumResult.session,
575-
startRumResult.viewHistory
580+
startRumResult.viewHistory,
581+
createEncoder
576582
)
577583

578584
strategy = createPostStartStrategy(strategy, startRumResult)

packages/rum-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,5 @@ export type { Hooks, DefaultRumEventAttributes, DefaultTelemetryEventAttributes
7171
export { createHooks } from './domain/hooks'
7272
export { isSampled } from './domain/sampler/sampler'
7373
export type { TracingOption, PropagatorType } from './domain/tracing/tracer.types'
74+
export type { TransportPayload } from './transport/formDataTransport'
75+
export { createFormDataTransport } from './transport/formDataTransport'
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { createIdentityEncoder, DeflateEncoderStreamId as CoreDeflateEncoderStreamId } from '@datadog/browser-core'
2+
import { interceptRequests, readFormDataRequest } from '@datadog/browser-core/test'
3+
import { LifeCycle } from '../domain/lifeCycle'
4+
import { mockRumConfiguration } from '../../test'
5+
import { createFormDataTransport } from './formDataTransport'
6+
7+
describe('createFormDataTransport', () => {
8+
function setup() {
9+
const interceptor = interceptRequests()
10+
const lifeCycle = new LifeCycle()
11+
const transport = createFormDataTransport(
12+
mockRumConfiguration(),
13+
lifeCycle,
14+
createIdentityEncoder,
15+
CoreDeflateEncoderStreamId.REPLAY
16+
)
17+
18+
return { interceptor, transport, lifeCycle }
19+
}
20+
21+
it('should send event and attachments as FormData', async () => {
22+
const { interceptor, transport } = setup()
23+
24+
const payload = {
25+
event: { type: 'test-event', data: 'test-data' },
26+
'attachment.json': { foo: 'bar' },
27+
}
28+
29+
await transport.send(payload)
30+
31+
expect(interceptor.requests).toHaveSize(1)
32+
expect(interceptor.requests[0].body).toBeInstanceOf(FormData)
33+
expect(await readFormDataRequest(interceptor.requests[0])).toEqual(payload)
34+
})
35+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type {
2+
Uint8ArrayBuffer,
3+
Encoder,
4+
EncoderResult,
5+
DeflateEncoderStreamId,
6+
RawError,
7+
Context,
8+
} from '@datadog/browser-core'
9+
import { addTelemetryDebug, createHttpRequest, jsonStringify, objectEntries } from '@datadog/browser-core'
10+
import type { RumConfiguration } from '../domain/configuration'
11+
import type { LifeCycle } from '../domain/lifeCycle'
12+
import { LifeCycleEventType } from '../domain/lifeCycle'
13+
14+
/**
15+
* transport payload consist of an event and one or more attachments
16+
*/
17+
export interface TransportPayload {
18+
event: Context
19+
[key: string]: Context
20+
}
21+
22+
export interface Transport<T extends TransportPayload> {
23+
send: (data: T) => Promise<void>
24+
}
25+
26+
export function createFormDataTransport<T extends TransportPayload>(
27+
configuration: RumConfiguration,
28+
lifeCycle: LifeCycle,
29+
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder,
30+
streamId: DeflateEncoderStreamId
31+
) {
32+
const reportError = (error: RawError) => {
33+
lifeCycle.notify(LifeCycleEventType.RAW_ERROR_COLLECTED, { error })
34+
35+
// monitor-until: forever, to keep an eye on the errors reported to customers
36+
addTelemetryDebug('Error reported to customer', { 'error.message': error.message })
37+
}
38+
39+
const httpRequest = createHttpRequest(
40+
[configuration.profilingEndpointBuilder],
41+
configuration.batchBytesLimit,
42+
reportError
43+
)
44+
45+
const encoder = createEncoder(streamId)
46+
47+
return {
48+
async send({ event, ...attachments }: T) {
49+
const formData = new FormData()
50+
const serializedEvent = jsonStringify(event)
51+
52+
if (!serializedEvent) {
53+
throw new Error('Failed to serialize event')
54+
}
55+
56+
formData.append('event', new Blob([serializedEvent], { type: 'application/json' }), 'event.json')
57+
58+
let bytesCount = serializedEvent.length
59+
60+
for (const [key, value] of objectEntries(attachments as Record<string, Context>)) {
61+
const serializedValue = jsonStringify(value)
62+
63+
if (!serializedValue) {
64+
throw new Error('Failed to serialize attachment')
65+
}
66+
67+
const result = await encode(encoder, serializedValue)
68+
69+
bytesCount += result.outputBytesCount
70+
formData.append(key, new Blob([result.output]), key)
71+
}
72+
73+
httpRequest.send({
74+
data: formData,
75+
bytesCount,
76+
})
77+
},
78+
}
79+
}
80+
81+
function encode<T extends string | Uint8ArrayBuffer>(encoder: Encoder<T>, data: string): Promise<EncoderResult<T>> {
82+
return new Promise((resolve) => {
83+
encoder.write(data)
84+
85+
encoder.finish((encoderResult) => {
86+
resolve(encoderResult)
87+
})
88+
})
89+
}

packages/rum/src/boot/profilerApi.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
ProfilerApi,
77
Hooks,
88
} from '@datadog/browser-rum-core'
9+
import type { DeflateEncoderStreamId, Encoder } from '@datadog/browser-core'
910
import { isSampled } from '@datadog/browser-rum-core'
1011
import { addTelemetryDebug, monitorError } from '@datadog/browser-core'
1112
import type { RUMProfiler } from '../domain/profiling/types'
@@ -21,7 +22,8 @@ export function makeProfilerApi(): ProfilerApi {
2122
hooks: Hooks,
2223
configuration: RumConfiguration,
2324
sessionManager: RumSessionManager,
24-
viewHistory: ViewHistory
25+
viewHistory: ViewHistory,
26+
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder
2527
) {
2628
const session = sessionManager.findTrackedSession() // Check if the session is tracked.
2729

@@ -59,7 +61,7 @@ export function makeProfilerApi(): ProfilerApi {
5961
return
6062
}
6163

62-
profiler = createRumProfiler(configuration, lifeCycle, sessionManager, profilingContextManager)
64+
profiler = createRumProfiler(configuration, lifeCycle, sessionManager, profilingContextManager, createEncoder)
6365
profiler.start(viewHistory.findView())
6466
})
6567
.catch(monitorError)

0 commit comments

Comments
 (0)