From 3c1e7270594835ec1cd3179e75d361be1aa20f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Olav=20L=C3=B8ite?= Date: Sun, 13 Sep 2026 09:14:54 +0200 Subject: [PATCH] perf(spanner): eliminate per-RPC regex operations in metrics layer Replaces regular expression matching across hot RPC metrics pathways with direct string searching and delimiter-based parsing: - Extracts GFE and AFE Server-Timing latencies using delimiter checks and ASCII digit accumulation, avoiding per-RPC regex instantiation and string allocations. - Replaces named-capture regular expressions in resource name parsing with positional path segment inspection. - Replaces regex parsing in request ID operation extraction with a single-pass delimiter scan. - Updates latency checks in the interceptor and tracer to explicitly check for numeric types, ensuring 0 ms latencies are recorded accurately instead of misclassified as connectivity errors. --- .../spanner/src/metrics/interceptor.ts | 13 +- .../src/metrics/metrics-tracer-factory.ts | 63 +++++-- .../spanner/src/metrics/metrics-tracer.ts | 102 +++++++++-- .../spanner/test/metrics/interceptor.ts | 65 ++++++- .../test/metrics/metrics-tracer-factory.ts | 164 +++++++++++++++++- .../spanner/test/metrics/metrics-tracer.ts | 96 ++++++++++ 6 files changed, 465 insertions(+), 38 deletions(-) diff --git a/handwritten/spanner/src/metrics/interceptor.ts b/handwritten/spanner/src/metrics/interceptor.ts index c3ec57acfd44..c405858e9cc6 100644 --- a/handwritten/spanner/src/metrics/interceptor.ts +++ b/handwritten/spanner/src/metrics/interceptor.ts @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ import {grpc} from 'google-gax'; import {MetricsTracerFactory} from './metrics-tracer-factory'; +const PROJECT_ID_REGEX = /^projects\/([^/]+)\//; + /** * Interceptor for recording metrics on gRPC calls. * @@ -33,7 +35,10 @@ export const MetricInterceptor = (options, nextCall) => { const resourcePrefix = metadata.get( 'google-cloud-resource-prefix', )[0] as string; - const match = resourcePrefix?.match(/^projects\/([^/]+)\//); + const match = + typeof resourcePrefix === 'string' + ? PROJECT_ID_REGEX.exec(resourcePrefix) + : null; const projectId = match ? match[1] : undefined; let factory; if (projectId) { @@ -67,12 +72,12 @@ export const MetricInterceptor = (options, nextCall) => { // Record attempt metric completion metricsTracer?.recordAttemptCompletion(status.code); - if (metricsTracer?.gfeLatency) { + if (typeof metricsTracer?.gfeLatency === 'number') { metricsTracer?.recordGfeLatency(status.code); } else { metricsTracer?.recordGfeConnectivityErrorCount(status.code); } - if (metricsTracer?.afeLatency) { + if (typeof metricsTracer?.afeLatency === 'number') { metricsTracer?.recordAfeLatency(status.code); } else { metricsTracer?.recordAfeConnectivityErrorCount(status.code); diff --git a/handwritten/spanner/src/metrics/metrics-tracer-factory.ts b/handwritten/spanner/src/metrics/metrics-tracer-factory.ts index d4de5f4c234b..1144364f5096 100644 --- a/handwritten/spanner/src/metrics/metrics-tracer-factory.ts +++ b/handwritten/spanner/src/metrics/metrics-tracer-factory.ts @@ -264,7 +264,11 @@ export class MetricsTracerFactory { * @param formattedName The formatted resource name (e.g., full database path). * @returns An object containing project, instance, and database strings. */ - public getInstanceAttributes(formattedName: string) { + public getInstanceAttributes(formattedName: string): { + project: string; + instance: string; + database: string; + } { if (typeof formattedName !== 'string' || formattedName === '') { return { project: Constants.UNKNOWN_ATTRIBUTE, @@ -272,12 +276,31 @@ export class MetricsTracerFactory { database: Constants.UNKNOWN_ATTRIBUTE, }; } - const regex = - /projects\/(?[^/]+)\/instances\/(?[^/]+)(?:\/databases\/(?[^/]+))?/; - const match = formattedName.match(regex); - const project = match?.groups?.projectId || Constants.UNKNOWN_ATTRIBUTE; - const instance = match?.groups?.instanceId || Constants.UNKNOWN_ATTRIBUTE; - const database = match?.groups?.databaseId || Constants.UNKNOWN_ATTRIBUTE; + const parts = formattedName.split('/'); + const startIndex = parts[0] === '' ? 1 : 0; + if ( + parts.length < startIndex + 4 || + parts[startIndex] !== 'projects' || + parts[startIndex + 2] !== 'instances' || + !parts[startIndex + 1] || + !parts[startIndex + 3] + ) { + return { + project: Constants.UNKNOWN_ATTRIBUTE, + instance: Constants.UNKNOWN_ATTRIBUTE, + database: Constants.UNKNOWN_ATTRIBUTE, + }; + } + const project = parts[startIndex + 1]; + const instance = parts[startIndex + 3]; + let database = Constants.UNKNOWN_ATTRIBUTE; + if ( + parts.length >= startIndex + 6 && + parts[startIndex + 4] === 'databases' && + parts[startIndex + 5] + ) { + database = parts[startIndex + 5]; + } return {project: project, instance: instance, database: database}; } @@ -316,19 +339,33 @@ export class MetricsTracerFactory { } private _extractOperationRequest(requestId: string): string { - if (!requestId) { + if (!requestId || typeof requestId !== 'string') { return ''; } - const regex = /^(\d+\.[a-z0-9]+\.\d+\.\d+\.\d+)\.\d+$/i; - const match = requestId.match(regex); + let dotCount = 0; + let fifthDotIndex = -1; + for (let index = 0; index < requestId.length; index++) { + if (requestId.charCodeAt(index) === 46 /* '.' */) { + dotCount++; + if (dotCount === 5) { + fifthDotIndex = index; + } + } + } - if (!match) { + if (dotCount !== 5 || fifthDotIndex === requestId.length - 1) { return ''; } - const request = match[1]; - return request; + for (let index = fifthDotIndex + 1; index < requestId.length; index++) { + const code = requestId.charCodeAt(index); + if (code < 48 || code > 57) { + return ''; + } + } + + return requestId.slice(0, fifthDotIndex); } /** diff --git a/handwritten/spanner/src/metrics/metrics-tracer.ts b/handwritten/spanner/src/metrics/metrics-tracer.ts index 709df2987939..9b3937ec741c 100644 --- a/handwritten/spanner/src/metrics/metrics-tracer.ts +++ b/handwritten/spanner/src/metrics/metrics-tracer.ts @@ -107,6 +107,92 @@ class MetricOperationTracer { } } +const GFE_METRIC_PREFIX = 'gfet4t7; dur='; +const AFE_METRIC_PREFIX = 'afe; dur='; + +/** + * Checks whether a character code represents an entry delimiter or whitespace in a + * 'server-timing' header (start of string, space, comma, or tab). + * + * @param charCode The character code to check. + * @returns True if the character code is a valid entry separator or whitespace. + */ +function isEntryDelimiter(charCode: number): boolean { + return ( + charCode === 32 /* ' ' */ || + charCode === 44 /* ',' */ || + charCode === 9 /* '\t' */ + ); +} + +/** + * Parses consecutive ASCII digit characters into an integer starting from the given index. + * Returns null if the character at startIndex is not a digit (e.g. non-numeric, negative, or empty). + * Parsing stops at the first non-digit (e.g. ',', ';', or decimal point), matching the legacy + * regex `([0-9]+)` behavior without intermediate string slice allocations. + * + * @param header The 'server-timing' header string. + * @param startIndex The index where numeric digits are expected to begin. + * @returns The parsed non-negative integer, or null if no valid digits were found. + */ +function parseConsecutiveDigits( + header: string, + startIndex: number, +): number | null { + if (startIndex >= header.length) { + return null; + } + const firstCharCode = header.charCodeAt(startIndex); + if (firstCharCode < 48 || firstCharCode > 57) { + return null; + } + let value = firstCharCode - 48; + for (let index = startIndex + 1; index < header.length; index++) { + const code = header.charCodeAt(index); + if (code >= 48 && code <= 57) { + value = value * 10 + (code - 48); + } else { + break; + } + } + return value; +} + +/** + * Extracts a numeric latency value in milliseconds for a given metric prefix from a + * 'server-timing' header string without regex allocations or intermediate substring slices. + * + * @param header The 'server-timing' header string. + * @param prefix The metric prefix (e.g. 'gfet4t7; dur='). + * @returns The extracted latency in milliseconds, or null if not found. + */ +function extractServerTimingLatency( + header: string, + prefix: string, +): number | null { + if (!header || typeof header !== 'string') { + return null; + } + let prefixIndex = header.indexOf(prefix); + while (prefixIndex !== -1) { + // Ensure prefix is not part of a longer metric name (e.g., 'safe; dur=' matching 'afe; dur=') + if ( + prefixIndex === 0 || + isEntryDelimiter(header.charCodeAt(prefixIndex - 1)) + ) { + const latency = parseConsecutiveDigits( + header, + prefixIndex + prefix.length, + ); + if (latency !== null) { + return latency; + } + } + prefixIndex = header.indexOf(prefix, prefixIndex + 1); + } + return null; +} + /** * MetricsTracer is responsible for recording and managing metrics related to * gRPC Spanner operations and attempts counters, and latencies, @@ -290,11 +376,7 @@ export class MetricsTracer { * @returns The extracted GFE latency in milliseconds, or null if not found. */ public extractGfeLatency(header: string): number | null { - const regex = /gfet4t7; dur=([0-9]+).*/; - if (header === undefined) return null; - const match = header.match(regex); - if (!match) return null; - return Number(match[1]); + return extractServerTimingLatency(header, GFE_METRIC_PREFIX); } /** @@ -306,11 +388,7 @@ export class MetricsTracer { */ public extractAfeLatency(header: string): number | null { if (!Spanner.isAFEServerTimingEnabled()) return null; - const regex = /afe; dur=([0-9]+).*/; - if (header === undefined) return null; - const match = header.match(regex); - if (!match) return null; - return Number(match[1]); + return extractServerTimingLatency(header, AFE_METRIC_PREFIX); } /** @@ -319,7 +397,7 @@ export class MetricsTracer { */ public recordGfeLatency(statusCode: Status) { if (!this.enabled) return; - if (!this.gfeLatency) { + if (typeof this.gfeLatency !== 'number') { console.error( 'ERROR: Attempted to record GFE metric with no latency value.', ); @@ -359,7 +437,7 @@ export class MetricsTracer { */ public recordAfeLatency(statusCode: Status) { if (!this.enabled || !Spanner.isAFEServerTimingEnabled()) return; - if (!this.afeLatency) { + if (typeof this.afeLatency !== 'number') { console.error( 'ERROR: Attempted to record AFE metric with no latency value.', ); diff --git a/handwritten/spanner/test/metrics/interceptor.ts b/handwritten/spanner/test/metrics/interceptor.ts index b28dd95bbfc3..f724a7b6949e 100644 --- a/handwritten/spanner/test/metrics/interceptor.ts +++ b/handwritten/spanner/test/metrics/interceptor.ts @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -50,6 +50,9 @@ describe('MetricInterceptor', () => { if (header === 'gfet4t7; dur=90, afe; dur=30') { return 90; } + if (header === 'gfet4t7; dur=0, afe; dur=0') { + return 0; + } return null; }) as sinon.SinonStub<[string], number | null>; mockMetricsTracer.extractAfeLatency = sandbox @@ -58,6 +61,9 @@ describe('MetricInterceptor', () => { if (header === 'gfet4t7; dur=90, afe; dur=30') { return 30; } + if (header === 'gfet4t7; dur=0, afe; dur=0') { + return 0; + } return null; }) as sinon.SinonStub<[string], number | null>; mockMetricsTracer.recordGfeLatency = sandbox.stub< @@ -214,5 +220,62 @@ describe('MetricInterceptor', () => { Status.OK, ); }); + + it('handles missing or non-matching resource prefix gracefully', () => { + const metadataWithoutPrefix = new grpc.Metadata(); + metadataWithoutPrefix.set( + 'x-goog-spanner-request-id', + '1.1a2b3c.1.1.1.1', + ); + + const interceptingCall1 = MetricInterceptor(mockOptions, mockNextCall); + interceptingCall1.start(metadataWithoutPrefix, mockListener); + assert.equal(mockMetricsTracer.recordAttemptStart.callCount, 0); + + const metadataWithNonMatchingPrefix = new grpc.Metadata(); + metadataWithNonMatchingPrefix.set( + 'google-cloud-resource-prefix', + 'invalid/prefix', + ); + metadataWithNonMatchingPrefix.set( + 'x-goog-spanner-request-id', + '1.1a2b3c.1.1.1.1', + ); + + const interceptingCall2 = MetricInterceptor(mockOptions, mockNextCall); + interceptingCall2.start(metadataWithNonMatchingPrefix, mockListener); + assert.equal(mockMetricsTracer.recordAttemptStart.callCount, 0); + }); + + it('GFE and AFE Metrics - zero latency', () => { + const zeroTimingMetadata = new grpc.Metadata(); + zeroTimingMetadata.set('server-timing', 'gfet4t7; dur=0, afe; dur=0'); + + const interceptingCall = MetricInterceptor(mockOptions, mockNextCall); + interceptingCall.start(testMetadata, mockListener); + + capturedListener.onReceiveMetadata(zeroTimingMetadata); + capturedListener.onReceiveStatus(mockStatus); + + assert.equal(mockMetricsTracer.recordGfeLatency.callCount, 1); + assert.equal( + mockMetricsTracer.recordGfeLatency.getCall(0).args[0], + Status.OK, + ); + assert.equal( + mockMetricsTracer.recordGfeConnectivityErrorCount.callCount, + 0, + ); + + assert.equal(mockMetricsTracer.recordAfeLatency.callCount, 1); + assert.equal( + mockMetricsTracer.recordAfeLatency.getCall(0).args[0], + Status.OK, + ); + assert.equal( + mockMetricsTracer.recordAfeConnectivityErrorCount.callCount, + 0, + ); + }); }); }); diff --git a/handwritten/spanner/test/metrics/metrics-tracer-factory.ts b/handwritten/spanner/test/metrics/metrics-tracer-factory.ts index 78dc1d27a132..0f1f85ad4eb7 100644 --- a/handwritten/spanner/test/metrics/metrics-tracer-factory.ts +++ b/handwritten/spanner/test/metrics/metrics-tracer-factory.ts @@ -209,8 +209,8 @@ describe('getInstanceAttributes', () => { it('should extract project, instance, and database from full resource path', () => { const formattedName = 'projects/proj1/instances/inst1/databases/db1'; - const attrs = factory.getInstanceAttributes(formattedName); - assert.deepStrictEqual(attrs, { + const attributes = factory.getInstanceAttributes(formattedName); + assert.deepStrictEqual(attributes, { project: 'proj1', instance: 'inst1', database: 'db1', @@ -219,8 +219,8 @@ describe('getInstanceAttributes', () => { it('should extract project and instance, and unknown database if database is missing', () => { const formattedName = 'projects/proj2/instances/inst2'; - const attrs = factory.getInstanceAttributes(formattedName); - assert.deepStrictEqual(attrs, { + const attributes = factory.getInstanceAttributes(formattedName); + assert.deepStrictEqual(attributes, { project: 'proj2', instance: 'inst2', database: 'unknown', @@ -228,8 +228,8 @@ describe('getInstanceAttributes', () => { }); it('should return unknown strings for all if input is empty', () => { - const attrs = factory.getInstanceAttributes(''); - assert.deepStrictEqual(attrs, { + const attributes = factory.getInstanceAttributes(''); + assert.deepStrictEqual(attributes, { project: 'unknown', instance: 'unknown', database: 'unknown', @@ -237,13 +237,161 @@ describe('getInstanceAttributes', () => { }); it('should return unknown strings for all if input is malformed', () => { - const attrs = factory.getInstanceAttributes('foo/bar/baz'); - assert.deepStrictEqual(attrs, { + const attributes = factory.getInstanceAttributes('foo/bar/baz'); + assert.deepStrictEqual(attributes, { project: 'unknown', instance: 'unknown', database: 'unknown', }); }); + + it('should extract attributes from path with leading slash', () => { + const attributes = factory.getInstanceAttributes( + '/projects/proj1/instances/inst1/databases/db1', + ); + assert.deepStrictEqual(attributes, { + project: 'proj1', + instance: 'inst1', + database: 'db1', + }); + }); + + it('should extract attributes from path with extra sub-resources', () => { + const attributes = factory.getInstanceAttributes( + 'projects/proj1/instances/inst1/databases/db1/sessions/session-xyz', + ); + assert.deepStrictEqual(attributes, { + project: 'proj1', + instance: 'inst1', + database: 'db1', + }); + }); + + it('should return unknown strings for non-string input', () => { + assert.deepStrictEqual(factory.getInstanceAttributes(null as any), { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }); + assert.deepStrictEqual(factory.getInstanceAttributes(undefined as any), { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }); + assert.deepStrictEqual(factory.getInstanceAttributes(123 as any), { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }); + }); + + it('should return unknown strings if second segment is not instances', () => { + const attributes = factory.getInstanceAttributes( + 'projects/proj1/locations/us-central1', + ); + assert.deepStrictEqual(attributes, { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }); + }); + + it('should return unknown strings if project or instance segment is empty', () => { + assert.deepStrictEqual( + factory.getInstanceAttributes('projects//instances/inst1'), + { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }, + ); + assert.deepStrictEqual( + factory.getInstanceAttributes('projects/proj1/instances/'), + { + project: 'unknown', + instance: 'unknown', + database: 'unknown', + }, + ); + }); + + it('should return unknown database if segment after instance is not databases or database ID is empty', () => { + assert.deepStrictEqual( + factory.getInstanceAttributes( + 'projects/proj1/instances/inst1/operations/op1', + ), + { + project: 'proj1', + instance: 'inst1', + database: 'unknown', + }, + ); + assert.deepStrictEqual( + factory.getInstanceAttributes( + 'projects/proj1/instances/inst1/databases/', + ), + { + project: 'proj1', + instance: 'inst1', + database: 'unknown', + }, + ); + }); +}); + +describe('_extractOperationRequest', () => { + let factory: MetricsTracerFactory; + beforeEach(() => { + factory = new (MetricsTracerFactory as any)(); + }); + + it('should extract the operation prefix from a valid request ID', () => { + const operationRequest = + factory['_extractOperationRequest']('1.1a2bc3d4.1.1.1.1'); + assert.strictEqual(operationRequest, '1.1a2bc3d4.1.1.1'); + }); + + it('should handle multi-digit attempt numbers', () => { + const operationRequest = factory['_extractOperationRequest']( + '1.1a2bc3d4.1.1.1.42', + ); + assert.strictEqual(operationRequest, '1.1a2bc3d4.1.1.1'); + }); + + it('should return empty string when attempt is not numeric', () => { + assert.strictEqual( + factory['_extractOperationRequest']('1.1a2bc3d4.1.1.1.attempt'), + '', + ); + }); + + it('should return empty string when input has fewer than 5 dots', () => { + assert.strictEqual( + factory['_extractOperationRequest']('1.1a2bc3d4.1.1.1'), + '', + ); + assert.strictEqual(factory['_extractOperationRequest']('foo.bar'), ''); + }); + + it('should return empty string when input has more than 5 dots', () => { + assert.strictEqual( + factory['_extractOperationRequest']('1.1.1.1.1.1.1'), + '', + ); + }); + + it('should return empty string for trailing dot or invalid input', () => { + assert.strictEqual( + factory['_extractOperationRequest']('1.1a2bc3d4.1.1.1.'), + '', + ); + assert.strictEqual(factory['_extractOperationRequest'](''), ''); + assert.strictEqual( + factory['_extractOperationRequest'](undefined as any), + '', + ); + assert.strictEqual(factory['_extractOperationRequest'](123 as any), ''); + }); }); describe('MetricsTracerFactory with set clock', () => { diff --git a/handwritten/spanner/test/metrics/metrics-tracer.ts b/handwritten/spanner/test/metrics/metrics-tracer.ts index 3cd5530f5bca..cdbb9a2c1263 100644 --- a/handwritten/spanner/test/metrics/metrics-tracer.ts +++ b/handwritten/spanner/test/metrics/metrics-tracer.ts @@ -194,6 +194,24 @@ describe('MetricsTracer', () => { assert.strictEqual(fakeGfeLatency.record.calledOnce, true); }); + it('should record GFE latency when value is zero', () => { + tracer.enabled = true; + tracer.gfeLatency = 0; + tracer.recordGfeLatency(Status.OK); + assert.strictEqual(fakeGfeLatency.record.calledOnce, true); + assert.strictEqual(fakeGfeLatency.record.getCall(0).args[0], 0); + assert.strictEqual(tracer.gfeLatency, null); + }); + + it('should not record GFE latency and log error when latency is null', () => { + const errorStub = sandbox.stub(console, 'error'); + tracer.enabled = true; + tracer.gfeLatency = null; + tracer.recordGfeLatency(Status.OK); + assert.strictEqual(fakeGfeLatency.record.called, false); + assert.strictEqual(errorStub.calledOnce, true); + }); + it('should not record if disabled', () => { tracer.enabled = false; tracer.gfeLatency = 123; @@ -228,6 +246,24 @@ describe('MetricsTracer', () => { assert.strictEqual(fakeAfeLatency.record.calledOnce, true); }); + it('should record AFE latency when value is zero', () => { + tracer.enabled = true; + tracer.afeLatency = 0; + tracer.recordAfeLatency(Status.OK); + assert.strictEqual(fakeAfeLatency.record.calledOnce, true); + assert.strictEqual(fakeAfeLatency.record.getCall(0).args[0], 0); + assert.strictEqual(tracer.afeLatency, null); + }); + + it('should not record AFE latency and log error when latency is null', () => { + const errorStub = sandbox.stub(console, 'error'); + tracer.enabled = true; + tracer.afeLatency = null; + tracer.recordAfeLatency(Status.OK); + assert.strictEqual(fakeAfeLatency.record.called, false); + assert.strictEqual(errorStub.calledOnce, true); + }); + it('should not record if AFE server timing is disabled', () => { tracer.enabled = true; Spanner._resetAFEServerTimingForTest(); @@ -331,5 +367,65 @@ describe('MetricsTracer', () => { const afeLatency = tracer.extractAfeLatency(header); assert.strictEqual(afeLatency, 30); }); + + it('should extract zero latency correctly', () => { + const header = 'gfet4t7; dur=0, afe; dur=0'; + assert.strictEqual(tracer.extractGfeLatency(header), 0); + assert.strictEqual(tracer.extractAfeLatency(header), 0); + }); + + it('should return null for empty or non-string header', () => { + assert.strictEqual(tracer.extractGfeLatency(''), null); + assert.strictEqual(tracer.extractAfeLatency(''), null); + assert.strictEqual(tracer.extractGfeLatency(null as any), null); + assert.strictEqual(tracer.extractAfeLatency(null as any), null); + assert.strictEqual(tracer.extractGfeLatency(123 as any), null); + assert.strictEqual(tracer.extractAfeLatency(123 as any), null); + }); + + it('should return null for non-numeric or negative durations', () => { + assert.strictEqual(tracer.extractGfeLatency('gfet4t7; dur='), null); + assert.strictEqual(tracer.extractGfeLatency('gfet4t7; dur=abc'), null); + assert.strictEqual(tracer.extractGfeLatency('gfet4t7; dur=-10'), null); + assert.strictEqual(tracer.extractAfeLatency('afe; dur='), null); + assert.strictEqual(tracer.extractAfeLatency('afe; dur=xyz'), null); + assert.strictEqual(tracer.extractAfeLatency('afe; dur=-5'), null); + }); + + it('should correctly distinguish prefixes embedded in longer metric names', () => { + const header = 'safe; dur=50, afe; dur=30'; + assert.strictEqual(tracer.extractGfeLatency(header), null); + assert.strictEqual(tracer.extractAfeLatency(header), 30); + }); + + it('should support various delimiters such as comma without space and tabs', () => { + const commaSeparated = 'other=val,afe; dur=40'; + assert.strictEqual(tracer.extractAfeLatency(commaSeparated), 40); + + const tabSeparated = 'other=val\tafe; dur=50'; + assert.strictEqual(tracer.extractAfeLatency(tabSeparated), 50); + }); + + it('should extract integer milliseconds from fractional durations', () => { + const header = 'gfet4t7; dur=123.45, afe; dur=67.89'; + assert.strictEqual(tracer.extractGfeLatency(header), 123); + assert.strictEqual(tracer.extractAfeLatency(header), 67); + }); + + it('should skip earlier invalid occurrences and find valid subsequent occurrence', () => { + const header = 'gfet4t7; dur=invalid, gfet4t7; dur=123'; + assert.strictEqual(tracer.extractGfeLatency(header), 123); + }); + + it('should return null when prefix is embedded in longer metric name with no subsequent match', () => { + assert.strictEqual(tracer.extractAfeLatency('safe; dur=50'), null); + assert.strictEqual(tracer.extractGfeLatency('notgfet4t7; dur=50'), null); + }); + + it('should return null if AFE server timing is disabled', () => { + sandbox.stub(Spanner, 'isAFEServerTimingEnabled').returns(false); + const header = 'afe; dur=30'; + assert.strictEqual(tracer.extractAfeLatency(header), null); + }); }); });