Skip to content

Commit 722972b

Browse files
harsh-joshi99claude
andcommitted
feat(ms-bing-ads-audiences): add flag-gated debug logging of API responses
Squashed from PR #3836. Logs a PII-safe summary of the Bing Ads API response (status, Microsoft tracking id, identifier type, item count, redacted PartialError codes) behind the 'actions-ms-bing-ads-audiences-debug-logging' feature flag. Does not log CustomerListItems or PartialError free-text fields. Temporary — for staging diagnosis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4618ee9 commit 722972b

2 files changed

Lines changed: 421 additions & 7 deletions

File tree

  • packages/destination-actions/src/destinations/ms-bing-ads-audiences/syncAudiences

packages/destination-actions/src/destinations/ms-bing-ads-audiences/syncAudiences/__tests__/index.test.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,295 @@ describe('MS Bing Ads Audiences syncAudiences', () => {
264264
expect(payloadArg[0].email).toBe('add1@segment.com')
265265
})
266266

267+
describe('debug logging (actions-ms-bing-ads-audiences-debug-logging flag)', () => {
268+
const DEBUG_FLAG = 'actions-ms-bing-ads-audiences-debug-logging'
269+
270+
const makeLogger = () =>
271+
({
272+
info: jest.fn(),
273+
error: jest.fn(),
274+
warn: jest.fn(),
275+
debug: jest.fn(),
276+
crit: jest.fn(),
277+
log: jest.fn(),
278+
withTags: jest.fn(),
279+
level: 'info',
280+
name: 'test'
281+
} as any)
282+
283+
const addEvent = () =>
284+
createTestEvent({
285+
type: 'identify',
286+
properties: { aud_key: true },
287+
context: { traits: { email: 'demo@segment.com' } }
288+
})
289+
290+
it('does not log when the flag is off', async () => {
291+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200, {})
292+
const logger = makeLogger()
293+
294+
await testDestination.testAction('syncAudiences', {
295+
event: addEvent(),
296+
mapping: baseMapping,
297+
useDefaultMappings: true,
298+
settings,
299+
logger,
300+
features: { [DEBUG_FLAG]: false }
301+
})
302+
303+
expect(logger.info).not.toHaveBeenCalled()
304+
expect(logger.error).not.toHaveBeenCalled()
305+
})
306+
307+
it('logs response metadata when the flag is on, without leaking CustomerListItems', async () => {
308+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200, { PartialErrors: [] })
309+
const logger = makeLogger()
310+
311+
await testDestination.testAction('syncAudiences', {
312+
event: addEvent(),
313+
mapping: baseMapping,
314+
useDefaultMappings: true,
315+
settings,
316+
logger,
317+
features: { [DEBUG_FLAG]: true }
318+
})
319+
320+
expect(logger.info).toHaveBeenCalledTimes(1)
321+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
322+
expect(logged).toContain('[ms-bing-ads-audiences][DEBUG]')
323+
expect(logged).toContain('identifierType=Email')
324+
expect(logged).toContain('itemCount=1')
325+
expect(logged).toContain('partialErrors=[]')
326+
// The hashed identifier must never be logged.
327+
expect(logged).not.toContain('5a95f052958dac8ed1d66d74eb481b3ccdbbc953b583c5ff0325be6b091d6281')
328+
})
329+
330+
it('logs the Microsoft tracking id from the response header', async () => {
331+
nock(BASE_URL)
332+
.post('/CustomerListUserData/Apply')
333+
.reply(200, { PartialErrors: [] }, { TrackingId: 'abc-123-track' })
334+
const logger = makeLogger()
335+
336+
await testDestination.testAction('syncAudiences', {
337+
event: addEvent(),
338+
mapping: baseMapping,
339+
useDefaultMappings: true,
340+
settings,
341+
logger,
342+
features: { [DEBUG_FLAG]: true }
343+
})
344+
345+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
346+
expect(logged).toContain('trackingId=abc-123-track')
347+
})
348+
349+
it('falls back to a body-level tracking id when no header is present', async () => {
350+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200, { TrackingId: 'body-track-789', PartialErrors: [] })
351+
const logger = makeLogger()
352+
353+
await testDestination.testAction('syncAudiences', {
354+
event: addEvent(),
355+
mapping: baseMapping,
356+
useDefaultMappings: true,
357+
settings,
358+
logger,
359+
features: { [DEBUG_FLAG]: true }
360+
})
361+
362+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
363+
expect(logged).toContain('trackingId=body-track-789')
364+
})
365+
366+
it('logs the tracking id in full, never truncated', async () => {
367+
// Even an unexpectedly long tracking id must be quotable to Microsoft support verbatim.
368+
const longTrackingId = `T-${'9'.repeat(6000)}`
369+
nock(BASE_URL)
370+
.post('/CustomerListUserData/Apply')
371+
.reply(200, { PartialErrors: [] }, { TrackingId: longTrackingId })
372+
const logger = makeLogger()
373+
374+
await testDestination.testAction('syncAudiences', {
375+
event: addEvent(),
376+
mapping: baseMapping,
377+
useDefaultMappings: true,
378+
settings,
379+
logger,
380+
features: { [DEBUG_FLAG]: true }
381+
})
382+
383+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
384+
expect(logged).toContain(`trackingId=${longTrackingId}`)
385+
expect(logged).not.toContain('[truncated]')
386+
})
387+
388+
it('redacts PartialError free-text fields that can echo identifiers', async () => {
389+
// Bing can echo the offending identifier in Message/Details/FieldPath. Only codes/index
390+
// should be logged, never the free-text fields.
391+
nock(BASE_URL)
392+
.post('/CustomerListUserData/Apply')
393+
.reply(200, {
394+
PartialErrors: [
395+
{
396+
ErrorCode: 'InvalidCustomerListItem',
397+
Code: 4001,
398+
Index: 0,
399+
Type: 'BatchError',
400+
Message: 'Invalid value crm_secret_12345',
401+
Details: 'crm_secret_12345',
402+
FieldPath: 'CustomerListItems[0]=crm_secret_12345'
403+
}
404+
]
405+
})
406+
const logger = makeLogger()
407+
408+
await testDestination.testBatchAction('syncAudiences', {
409+
events: [addEvent()],
410+
mapping: baseMapping,
411+
useDefaultMappings: true,
412+
settings,
413+
logger,
414+
features: { [DEBUG_FLAG]: true }
415+
})
416+
417+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
418+
expect(logged).toContain('ErrorCode')
419+
expect(logged).toContain('InvalidCustomerListItem')
420+
// The free-text fields (and any identifier they echo) must not be logged.
421+
expect(logged).not.toContain('crm_secret_12345')
422+
expect(logged).not.toContain('Message')
423+
expect(logged).not.toContain('FieldPath')
424+
})
425+
426+
it('strips control characters from logged content to prevent log injection', async () => {
427+
// A crafted response body with newlines must not be able to forge log lines.
428+
nock(BASE_URL)
429+
.post('/CustomerListUserData/Apply')
430+
.reply(200, {
431+
PartialErrors: [
432+
{ ErrorCode: 'X\nInjected', Code: 1, Index: 0, Type: 'T', Message: null, Details: null, FieldPath: null }
433+
]
434+
})
435+
const logger = makeLogger()
436+
437+
await testDestination.testBatchAction('syncAudiences', {
438+
events: [addEvent()],
439+
mapping: baseMapping,
440+
useDefaultMappings: true,
441+
settings,
442+
logger,
443+
features: { [DEBUG_FLAG]: true }
444+
})
445+
446+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
447+
expect(logged).not.toContain('\n')
448+
})
449+
450+
it('sanitizes a crafted audience id to prevent log injection', async () => {
451+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200, { PartialErrors: [] })
452+
const logger = makeLogger()
453+
454+
await testDestination.testAction('syncAudiences', {
455+
event: addEvent(),
456+
mapping: { ...baseMapping, audience_id: 'aud\n_injected' },
457+
useDefaultMappings: true,
458+
settings,
459+
logger,
460+
features: { [DEBUG_FLAG]: true }
461+
})
462+
463+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
464+
expect(logged).not.toContain('\n')
465+
})
466+
467+
it('truncates oversized values without exceeding the cap', async () => {
468+
// A long PartialError code forces truncation; the resulting log line's summary segment
469+
// (including the suffix) must stay within the configured cap.
470+
const longCode = 'C'.repeat(10000)
471+
nock(BASE_URL)
472+
.post('/CustomerListUserData/Apply')
473+
.reply(200, {
474+
PartialErrors: [
475+
{ ErrorCode: longCode, Code: 1, Index: 0, Type: 'T', Message: null, Details: null, FieldPath: null }
476+
]
477+
})
478+
const logger = makeLogger()
479+
480+
await testDestination.testBatchAction('syncAudiences', {
481+
events: [addEvent()],
482+
mapping: baseMapping,
483+
useDefaultMappings: true,
484+
settings,
485+
logger,
486+
features: { [DEBUG_FLAG]: true }
487+
})
488+
489+
const logged = (logger.info as jest.Mock).mock.calls[0][0] as string
490+
const partialErrors = logged.split('partialErrors=')[1]
491+
expect(partialErrors).toContain('[truncated]')
492+
// The truncated segment (suffix included) must not exceed the 4096 cap.
493+
expect(partialErrors.length).toBeLessThanOrEqual(4096)
494+
})
495+
496+
it('does not let a throwing logger break the action', async () => {
497+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200, { PartialErrors: [] })
498+
const logger = makeLogger()
499+
;(logger.info as jest.Mock).mockImplementation(() => {
500+
throw new Error('logger exploded')
501+
})
502+
503+
// A throwing logger must be swallowed so the action still succeeds.
504+
const response = await testDestination.testAction('syncAudiences', {
505+
event: addEvent(),
506+
mapping: baseMapping,
507+
useDefaultMappings: true,
508+
settings,
509+
logger,
510+
features: { [DEBUG_FLAG]: true }
511+
})
512+
513+
expect(response[0].status).toBe(200)
514+
})
515+
516+
it('does not crash when the response body is empty', async () => {
517+
// Empty body => response.data is undefined; logging must still succeed.
518+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(200)
519+
const logger = makeLogger()
520+
521+
const response = await testDestination.testAction('syncAudiences', {
522+
event: addEvent(),
523+
mapping: baseMapping,
524+
useDefaultMappings: true,
525+
settings,
526+
logger,
527+
features: { [DEBUG_FLAG]: true }
528+
})
529+
530+
expect(logger.info).toHaveBeenCalledTimes(1)
531+
expect(response[0].status).toBe(200)
532+
})
533+
534+
it('does not log on the error path (only success responses are logged)', async () => {
535+
nock(BASE_URL).post('/CustomerListUserData/Apply').reply(500, { message: 'boom' })
536+
const logger = makeLogger()
537+
538+
const response = await testDestination.testBatchAction('syncAudiences', {
539+
events: [addEvent()],
540+
mapping: baseMapping,
541+
useDefaultMappings: true,
542+
settings,
543+
logger,
544+
features: { [DEBUG_FLAG]: true }
545+
})
546+
547+
// We only log successful responses; the error path emits nothing.
548+
expect(logger.info).not.toHaveBeenCalled()
549+
expect(logger.error).not.toHaveBeenCalled()
550+
// The HTTP error is still handled normally.
551+
expect(utils.handleHttpError).toHaveBeenCalled()
552+
expect(response[0].status).toBe(500)
553+
})
554+
})
555+
267556
it('should throw non-HTTP errors in batch mode', async () => {
268557
// Create a custom error that is NOT an HTTPError
269558
const customError = new Error('Custom non-HTTP error')

0 commit comments

Comments
 (0)