From e1e5550c39e1fa58f348c781cdbdd13089c2619f Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Wed, 13 May 2026 12:57:40 -0400 Subject: [PATCH 1/4] feat(agent): topTalkersByBytes includes src display names ## Added - Per-source terms sub-agg on srcDisplayNameField when field caps show it as aggregatable; rows gain topSrcDisplayNames (displayName + docCount). - Skips duplicate terms agg when display field matches pod name field. ## Changed - Tool spec and FLOW_ANALYTICS playbook text for the LLM. --- src/agent/prompts/agentPrompt.ts | 2 +- src/agent/tools/handlers/topTalkersByBytes.ts | 12 +- src/agent/tools/toolSpecs.ts | 2 +- test/topTalkersByBytes.test.ts | 110 ++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 test/topTalkersByBytes.test.ts diff --git a/src/agent/prompts/agentPrompt.ts b/src/agent/prompts/agentPrompt.ts index abc9040..314d851 100644 --- a/src/agent/prompts/agentPrompt.ts +++ b/src/agent/prompts/agentPrompt.ts @@ -80,7 +80,7 @@ function flowPlaybooksCompressed(): string[] { '- State plainly when expected fields are missing (AZ, nodes, duration, tcp flags, etc.).', '', 'Examples (not exhaustive):', - '- topTalkersByBytes: set includeDistinctPods when pod names matter.', + '- topTalkersByBytes: rows include topSrcDisplayNames when mapped; set includeDistinctPods when pod cardinality matters.', '- namespaceTrafficMatrix: compare internal vs external bytes by namespace.', '- egressBytesVsBaseline vs egressSpikeDrilldown: table-only baseline vs per-source top destinations ' + 'in one call.', diff --git a/src/agent/tools/handlers/topTalkersByBytes.ts b/src/agent/tools/handlers/topTalkersByBytes.ts index 8b6319f..024fce3 100644 --- a/src/agent/tools/handlers/topTalkersByBytes.ts +++ b/src/agent/tools/handlers/topTalkersByBytes.ts @@ -41,9 +41,10 @@ export async function topTalkersByBytes( return undefined; }; - const [podNameAggField, nsAggField] = await Promise.all([ + const [podNameAggField, nsAggField, displayNameAggField] = await Promise.all([ pickAggField(fields.podNameField), pickAggField(fields.clientNamespaceField), + pickAggField(fields.srcDisplayNameField), ]); const bySrcAggs: Record = { @@ -58,6 +59,9 @@ export async function topTalkersByBytes( if (nsAggField) { bySrcAggs.top_namespaces = { terms: { field: nsAggField, size: 3 } }; } + if (displayNameAggField && displayNameAggField !== podNameAggField) { + bySrcAggs.top_display_names = { terms: { field: displayNameAggField, size: 3 } }; + } const { body } = await ctx.client.search({ index, @@ -106,6 +110,12 @@ export async function topTalkersByBytes( docCount: getNumber(nb['doc_count']), })); } + if (displayNameAggField && displayNameAggField !== podNameAggField) { + row.topSrcDisplayNames = getAggBuckets(b, ['top_display_names', 'buckets']).map((db) => ({ + displayName: getString(db['key']), + docCount: getNumber(db['doc_count']), + })); + } return row; }), }; diff --git a/src/agent/tools/toolSpecs.ts b/src/agent/tools/toolSpecs.ts index 21a158d..f881353 100644 --- a/src/agent/tools/toolSpecs.ts +++ b/src/agent/tools/toolSpecs.ts @@ -76,7 +76,7 @@ export const coreToolSpecs: readonly CoreToolSpec[] = [ { name: 'topTalkersByBytes', description: - 'Top source IPs by bytes; includes top pod names / namespaces when mapped; includeDistinctPods adds approximate distinct pod-name cardinality.', + 'Top source IPs by bytes. Per source: topSrcDisplayNames (hostname/pod-style labels when the index maps srcDisplayNameField), top pod names/namespaces when present; includeDistinctPods adds approximate distinct pod-name cardinality.', argsSchema: { type: 'object', properties: { diff --git a/test/topTalkersByBytes.test.ts b/test/topTalkersByBytes.test.ts new file mode 100644 index 0000000..3657a09 --- /dev/null +++ b/test/topTalkersByBytes.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defaultAgentPolicy } from '../src/agent/policy.js'; +import * as fieldCaps from '../src/opensearch/fieldCaps.js'; +import { topTalkersByBytes } from '../src/agent/tools/handlers/topTalkersByBytes.js'; + +vi.mock('../src/opensearch/fieldCaps.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, chooseFields: vi.fn() }; +}); + +describe('topTalkersByBytes', () => { + beforeEach(() => { + vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ + bytesField: 'flow.bytes', + srcIpField: 'source.ip', + dstIpField: 'dest.ip', + srcPortField: 'source.port', + dstPortField: 'dest.port', + srcDisplayNameField: 'host.name', + }); + }); + + it('adds top_display_names agg and topSrcDisplayNames on rows when display field is aggregatable', async () => { + const fieldCapsResp = (fields: string[]) => ({ + body: { + fields: Object.fromEntries( + fields.map((f) => [f, { keyword: { aggregatable: true } }]), + ), + }, + }); + const client = { + fieldCaps: vi.fn().mockImplementation((opts: { fields?: string[] }) => + Promise.resolve(fieldCapsResp(opts.fields ?? [])), + ), + search: vi.fn().mockResolvedValue({ + body: { + aggregations: { + by_src: { + buckets: [ + { + key: '192.168.1.1', + doc_count: 10, + sum_bytes: { value: 900 }, + top_display_names: { + buckets: [{ key: 'workstation.local', doc_count: 9 }], + }, + }, + ], + }, + }, + }, + }), + }; + + const out = (await topTalkersByBytes( + { client: client as never, policy: defaultAgentPolicy, defaultIndex: 'elastiflow-flow-codex-*' }, + {}, + )) as { + talkers: Array<{ srcIp: string; topSrcDisplayNames?: { displayName: string; docCount: number }[] }>; + }; + + const searchBody = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: unknown } } } }; + expect(searchBody.body?.aggs?.by_src?.aggs).toMatchObject({ + top_display_names: { terms: { field: 'host.name', size: 3 } }, + }); + expect(out.talkers[0]?.srcIp).toBe('192.168.1.1'); + expect(out.talkers[0]?.topSrcDisplayNames).toEqual([{ displayName: 'workstation.local', docCount: 9 }]); + }); + + it('omits top_display_names when it would duplicate the pod terms field', async () => { + vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ + bytesField: 'flow.bytes', + srcIpField: 'source.ip', + dstIpField: 'dest.ip', + srcPortField: 'source.port', + dstPortField: 'dest.port', + podNameField: 'k8s.pod', + srcDisplayNameField: 'k8s.pod', + }); + const client = { + fieldCaps: vi.fn().mockResolvedValue({ + body: { fields: { 'k8s.pod': { keyword: { aggregatable: true } }, 'k8s.pod.keyword': { keyword: { aggregatable: true } } } }, + }), + search: vi.fn().mockResolvedValue({ + body: { + aggregations: { + by_src: { + buckets: [ + { + key: '10.0.0.1', + doc_count: 3, + sum_bytes: { value: 100 }, + top_pods: { buckets: [{ key: 'pod-a', doc_count: 3 }] }, + }, + ], + }, + }, + }, + }), + }; + + await topTalkersByBytes( + { client: client as never, policy: defaultAgentPolicy, defaultIndex: 'elastiflow-flow-codex-*' }, + {}, + ); + + const searchBody = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; + expect(searchBody.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + }); +}); From 7ce42413ac6f0fa1d639101ecbfd17fc1fcf6dfc Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Wed, 13 May 2026 12:59:05 -0400 Subject: [PATCH 2/4] style(topTalkers): dedupe display agg guard; tighten copy and tests --- src/agent/prompts/agentPrompt.ts | 2 +- src/agent/tools/handlers/topTalkersByBytes.ts | 8 ++- src/agent/tools/toolSpecs.ts | 2 +- test/topTalkersByBytes.test.ts | 72 +++++++++---------- 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/agent/prompts/agentPrompt.ts b/src/agent/prompts/agentPrompt.ts index 314d851..4baa5c7 100644 --- a/src/agent/prompts/agentPrompt.ts +++ b/src/agent/prompts/agentPrompt.ts @@ -80,7 +80,7 @@ function flowPlaybooksCompressed(): string[] { '- State plainly when expected fields are missing (AZ, nodes, duration, tcp flags, etc.).', '', 'Examples (not exhaustive):', - '- topTalkersByBytes: rows include topSrcDisplayNames when mapped; set includeDistinctPods when pod cardinality matters.', + '- topTalkersByBytes: prefer topSrcDisplayNames in prose when set; includeDistinctPods only for pod cardinality.', '- namespaceTrafficMatrix: compare internal vs external bytes by namespace.', '- egressBytesVsBaseline vs egressSpikeDrilldown: table-only baseline vs per-source top destinations ' + 'in one call.', diff --git a/src/agent/tools/handlers/topTalkersByBytes.ts b/src/agent/tools/handlers/topTalkersByBytes.ts index 024fce3..93d4d1b 100644 --- a/src/agent/tools/handlers/topTalkersByBytes.ts +++ b/src/agent/tools/handlers/topTalkersByBytes.ts @@ -46,6 +46,8 @@ export async function topTalkersByBytes( pickAggField(fields.clientNamespaceField), pickAggField(fields.srcDisplayNameField), ]); + const displayAggField = + displayNameAggField && displayNameAggField !== podNameAggField ? displayNameAggField : undefined; const bySrcAggs: Record = { sum_bytes: { sum: { field: fields.bytesField } }, @@ -59,8 +61,8 @@ export async function topTalkersByBytes( if (nsAggField) { bySrcAggs.top_namespaces = { terms: { field: nsAggField, size: 3 } }; } - if (displayNameAggField && displayNameAggField !== podNameAggField) { - bySrcAggs.top_display_names = { terms: { field: displayNameAggField, size: 3 } }; + if (displayAggField) { + bySrcAggs.top_display_names = { terms: { field: displayAggField, size: 3 } }; } const { body } = await ctx.client.search({ @@ -110,7 +112,7 @@ export async function topTalkersByBytes( docCount: getNumber(nb['doc_count']), })); } - if (displayNameAggField && displayNameAggField !== podNameAggField) { + if (displayAggField) { row.topSrcDisplayNames = getAggBuckets(b, ['top_display_names', 'buckets']).map((db) => ({ displayName: getString(db['key']), docCount: getNumber(db['doc_count']), diff --git a/src/agent/tools/toolSpecs.ts b/src/agent/tools/toolSpecs.ts index f881353..0b0f0e3 100644 --- a/src/agent/tools/toolSpecs.ts +++ b/src/agent/tools/toolSpecs.ts @@ -76,7 +76,7 @@ export const coreToolSpecs: readonly CoreToolSpec[] = [ { name: 'topTalkersByBytes', description: - 'Top source IPs by bytes. Per source: topSrcDisplayNames (hostname/pod-style labels when the index maps srcDisplayNameField), top pod names/namespaces when present; includeDistinctPods adds approximate distinct pod-name cardinality.', + 'Top source IPs by bytes. Rows: topSrcDisplayNames (srcDisplayNameField when aggregatable), topPodNames/topNamespaces when mapped; includeDistinctPods for pod cardinality.', argsSchema: { type: 'object', properties: { diff --git a/test/topTalkersByBytes.test.ts b/test/topTalkersByBytes.test.ts index 3657a09..ba38218 100644 --- a/test/topTalkersByBytes.test.ts +++ b/test/topTalkersByBytes.test.ts @@ -8,29 +8,27 @@ vi.mock('../src/opensearch/fieldCaps.js', async (importActual) => { return { ...actual, chooseFields: vi.fn() }; }); +const index = 'elastiflow-flow-codex-*'; +const baseFields = { + bytesField: 'flow.bytes', + srcIpField: 'source.ip', + dstIpField: 'dest.ip', + srcPortField: 'source.port', + dstPortField: 'dest.port', +}; + describe('topTalkersByBytes', () => { beforeEach(() => { - vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ - bytesField: 'flow.bytes', - srcIpField: 'source.ip', - dstIpField: 'dest.ip', - srcPortField: 'source.port', - dstPortField: 'dest.port', - srcDisplayNameField: 'host.name', - }); + vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ ...baseFields, srcDisplayNameField: 'host.name' }); }); - it('adds top_display_names agg and topSrcDisplayNames on rows when display field is aggregatable', async () => { - const fieldCapsResp = (fields: string[]) => ({ - body: { - fields: Object.fromEntries( - fields.map((f) => [f, { keyword: { aggregatable: true } }]), - ), - }, + it('adds top_display_names and topSrcDisplayNames when display field aggregates', async () => { + const capsFromFields = (fields: string[]) => ({ + body: { fields: Object.fromEntries(fields.map((f) => [f, { keyword: { aggregatable: true } }])) }, }); const client = { fieldCaps: vi.fn().mockImplementation((opts: { fields?: string[] }) => - Promise.resolve(fieldCapsResp(opts.fields ?? [])), + Promise.resolve(capsFromFields(opts.fields ?? [])), ), search: vi.fn().mockResolvedValue({ body: { @@ -41,9 +39,7 @@ describe('topTalkersByBytes', () => { key: '192.168.1.1', doc_count: 10, sum_bytes: { value: 900 }, - top_display_names: { - buckets: [{ key: 'workstation.local', doc_count: 9 }], - }, + top_display_names: { buckets: [{ key: 'workstation.local', doc_count: 9 }] }, }, ], }, @@ -52,35 +48,34 @@ describe('topTalkersByBytes', () => { }), }; - const out = (await topTalkersByBytes( - { client: client as never, policy: defaultAgentPolicy, defaultIndex: 'elastiflow-flow-codex-*' }, - {}, - )) as { + const out = (await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {})) as { talkers: Array<{ srcIp: string; topSrcDisplayNames?: { displayName: string; docCount: number }[] }>; }; - const searchBody = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: unknown } } } }; - expect(searchBody.body?.aggs?.by_src?.aggs).toMatchObject({ + const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: unknown } } } }; + expect(body.body?.aggs?.by_src?.aggs).toMatchObject({ top_display_names: { terms: { field: 'host.name', size: 3 } }, }); expect(out.talkers[0]?.srcIp).toBe('192.168.1.1'); expect(out.talkers[0]?.topSrcDisplayNames).toEqual([{ displayName: 'workstation.local', docCount: 9 }]); }); - it('omits top_display_names when it would duplicate the pod terms field', async () => { + it('skips top_display_names when same field as pod terms', async () => { vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ - bytesField: 'flow.bytes', - srcIpField: 'source.ip', - dstIpField: 'dest.ip', - srcPortField: 'source.port', - dstPortField: 'dest.port', + ...baseFields, podNameField: 'k8s.pod', srcDisplayNameField: 'k8s.pod', }); + const podCaps = { + body: { + fields: { + 'k8s.pod': { keyword: { aggregatable: true } }, + 'k8s.pod.keyword': { keyword: { aggregatable: true } }, + }, + }, + }; const client = { - fieldCaps: vi.fn().mockResolvedValue({ - body: { fields: { 'k8s.pod': { keyword: { aggregatable: true } }, 'k8s.pod.keyword': { keyword: { aggregatable: true } } } }, - }), + fieldCaps: vi.fn().mockResolvedValue(podCaps), search: vi.fn().mockResolvedValue({ body: { aggregations: { @@ -99,12 +94,9 @@ describe('topTalkersByBytes', () => { }), }; - await topTalkersByBytes( - { client: client as never, policy: defaultAgentPolicy, defaultIndex: 'elastiflow-flow-codex-*' }, - {}, - ); + await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {}); - const searchBody = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; - expect(searchBody.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; + expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); }); }); From 5f06f3249f27eea61f0319ff5fad0c82e9f6c63b Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Wed, 13 May 2026 13:03:47 -0400 Subject: [PATCH 3/4] fix(agent): dedupe topTalkers display terms vs namespace; add tests - Skip top_display_names when display agg field matches namespace terms - Test non-aggregatable srcDisplayNameField (no agg, no topSrcDisplayNames) - Test display/namespace field collision - Tool spec note on dedupe Tool results remain unstructured (ToolResult.result: unknown); no Zod/OpenAPI surface for per-tool payloads. --- src/agent/tools/handlers/topTalkersByBytes.ts | 6 +- src/agent/tools/toolSpecs.ts | 2 +- test/topTalkersByBytes.test.ts | 66 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/agent/tools/handlers/topTalkersByBytes.ts b/src/agent/tools/handlers/topTalkersByBytes.ts index 93d4d1b..9428b13 100644 --- a/src/agent/tools/handlers/topTalkersByBytes.ts +++ b/src/agent/tools/handlers/topTalkersByBytes.ts @@ -47,7 +47,11 @@ export async function topTalkersByBytes( pickAggField(fields.srcDisplayNameField), ]); const displayAggField = - displayNameAggField && displayNameAggField !== podNameAggField ? displayNameAggField : undefined; + displayNameAggField && + displayNameAggField !== podNameAggField && + displayNameAggField !== nsAggField + ? displayNameAggField + : undefined; const bySrcAggs: Record = { sum_bytes: { sum: { field: fields.bytesField } }, diff --git a/src/agent/tools/toolSpecs.ts b/src/agent/tools/toolSpecs.ts index 0b0f0e3..fb5aaaa 100644 --- a/src/agent/tools/toolSpecs.ts +++ b/src/agent/tools/toolSpecs.ts @@ -76,7 +76,7 @@ export const coreToolSpecs: readonly CoreToolSpec[] = [ { name: 'topTalkersByBytes', description: - 'Top source IPs by bytes. Rows: topSrcDisplayNames (srcDisplayNameField when aggregatable), topPodNames/topNamespaces when mapped; includeDistinctPods for pod cardinality.', + 'Top source IPs by bytes. Rows: topSrcDisplayNames (srcDisplayNameField when aggregatable and not the same terms field as pods/namespaces), topPodNames/topNamespaces when mapped; includeDistinctPods for pod cardinality.', argsSchema: { type: 'object', properties: { diff --git a/test/topTalkersByBytes.test.ts b/test/topTalkersByBytes.test.ts index ba38218..4a9896b 100644 --- a/test/topTalkersByBytes.test.ts +++ b/test/topTalkersByBytes.test.ts @@ -99,4 +99,70 @@ describe('topTalkersByBytes', () => { const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); }); + + it('omits display agg when field caps mark srcDisplayNameField non-aggregatable', async () => { + const client = { + fieldCaps: vi.fn().mockResolvedValue({ + body: { fields: { 'host.name': { keyword: { aggregatable: false } } } }, + }), + search: vi.fn().mockResolvedValue({ + body: { + aggregations: { + by_src: { + buckets: [{ key: '192.168.1.2', doc_count: 5, sum_bytes: { value: 50 } }], + }, + }, + }, + }), + }; + + const out = (await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {})) as { + talkers: Array>; + }; + + const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; + expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + expect(out.talkers[0]).not.toHaveProperty('topSrcDisplayNames'); + }); + + it('skips top_display_names when display terms field matches namespace terms field', async () => { + vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ + ...baseFields, + clientNamespaceField: 'k8s.ns', + srcDisplayNameField: 'k8s.ns', + }); + const caps = { + body: { + fields: { + 'k8s.ns': { keyword: { aggregatable: true } }, + 'k8s.ns.keyword': { keyword: { aggregatable: true } }, + }, + }, + }; + const client = { + fieldCaps: vi.fn().mockResolvedValue(caps), + search: vi.fn().mockResolvedValue({ + body: { + aggregations: { + by_src: { + buckets: [ + { + key: '10.0.0.2', + doc_count: 2, + sum_bytes: { value: 20 }, + top_namespaces: { buckets: [{ key: 'default', doc_count: 2 }] }, + }, + ], + }, + }, + }, + }), + }; + + await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {}); + + const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; + expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + expect(body.body?.aggs?.by_src?.aggs?.top_namespaces).toMatchObject({ terms: { field: 'k8s.ns', size: 3 } }); + }); }); From 4fcb2e48e9b1d672270c3f46ce33294c03082744 Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Wed, 13 May 2026 13:05:15 -0400 Subject: [PATCH 4/4] refactor(agent): tighten topTalkersByBytes display logic and tests ## Changed - Skip display-name terms agg when it matches pod or namespace terms field. - Shorten topTalkersByBytes tool description. - Assert by_src sub-aggs via a small bySrcAggs helper; fix namespace collision test. --- src/agent/tools/handlers/topTalkersByBytes.ts | 4 +--- src/agent/tools/toolSpecs.ts | 2 +- test/topTalkersByBytes.test.ts | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/agent/tools/handlers/topTalkersByBytes.ts b/src/agent/tools/handlers/topTalkersByBytes.ts index 9428b13..a5fc273 100644 --- a/src/agent/tools/handlers/topTalkersByBytes.ts +++ b/src/agent/tools/handlers/topTalkersByBytes.ts @@ -47,9 +47,7 @@ export async function topTalkersByBytes( pickAggField(fields.srcDisplayNameField), ]); const displayAggField = - displayNameAggField && - displayNameAggField !== podNameAggField && - displayNameAggField !== nsAggField + displayNameAggField && displayNameAggField !== podNameAggField && displayNameAggField !== nsAggField ? displayNameAggField : undefined; diff --git a/src/agent/tools/toolSpecs.ts b/src/agent/tools/toolSpecs.ts index fb5aaaa..a8b6626 100644 --- a/src/agent/tools/toolSpecs.ts +++ b/src/agent/tools/toolSpecs.ts @@ -76,7 +76,7 @@ export const coreToolSpecs: readonly CoreToolSpec[] = [ { name: 'topTalkersByBytes', description: - 'Top source IPs by bytes. Rows: topSrcDisplayNames (srcDisplayNameField when aggregatable and not the same terms field as pods/namespaces), topPodNames/topNamespaces when mapped; includeDistinctPods for pod cardinality.', + 'Top sources by bytes. topSrcDisplayNames when srcDisplayNameField aggregates and differs from pod/namespace terms aggs; topPodNames/topNamespaces; includeDistinctPods for pod cardinality.', argsSchema: { type: 'object', properties: { diff --git a/test/topTalkersByBytes.test.ts b/test/topTalkersByBytes.test.ts index 4a9896b..41e08c6 100644 --- a/test/topTalkersByBytes.test.ts +++ b/test/topTalkersByBytes.test.ts @@ -17,6 +17,11 @@ const baseFields = { dstPortField: 'dest.port', }; +function bySrcAggs(client: { search: ReturnType }) { + return (client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }) + .body?.aggs?.by_src?.aggs; +} + describe('topTalkersByBytes', () => { beforeEach(() => { vi.mocked(fieldCaps.chooseFields).mockResolvedValue({ ...baseFields, srcDisplayNameField: 'host.name' }); @@ -52,8 +57,7 @@ describe('topTalkersByBytes', () => { talkers: Array<{ srcIp: string; topSrcDisplayNames?: { displayName: string; docCount: number }[] }>; }; - const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: unknown } } } }; - expect(body.body?.aggs?.by_src?.aggs).toMatchObject({ + expect(bySrcAggs(client)).toMatchObject({ top_display_names: { terms: { field: 'host.name', size: 3 } }, }); expect(out.talkers[0]?.srcIp).toBe('192.168.1.1'); @@ -96,8 +100,7 @@ describe('topTalkersByBytes', () => { await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {}); - const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; - expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + expect(bySrcAggs(client)?.top_display_names).toBeUndefined(); }); it('omits display agg when field caps mark srcDisplayNameField non-aggregatable', async () => { @@ -120,8 +123,7 @@ describe('topTalkersByBytes', () => { talkers: Array>; }; - const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; - expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); + expect(bySrcAggs(client)?.top_display_names).toBeUndefined(); expect(out.talkers[0]).not.toHaveProperty('topSrcDisplayNames'); }); @@ -161,8 +163,7 @@ describe('topTalkersByBytes', () => { await topTalkersByBytes({ client: client as never, policy: defaultAgentPolicy, defaultIndex: index }, {}); - const body = client.search.mock.calls[0]?.[0] as { body?: { aggs?: { by_src?: { aggs?: Record } } } }; - expect(body.body?.aggs?.by_src?.aggs?.top_display_names).toBeUndefined(); - expect(body.body?.aggs?.by_src?.aggs?.top_namespaces).toMatchObject({ terms: { field: 'k8s.ns', size: 3 } }); + expect(bySrcAggs(client)?.top_display_names).toBeUndefined(); + expect(bySrcAggs(client)?.top_namespaces).toMatchObject({ terms: { field: 'k8s.ns', size: 3 } }); }); });