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
11 changes: 8 additions & 3 deletions handwritten/spanner/src/metrics/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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) {
Expand Down Expand Up @@ -71,12 +76,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);
Expand Down
63 changes: 50 additions & 13 deletions handwritten/spanner/src/metrics/metrics-tracer-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,20 +264,43 @@ 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,
instance: Constants.UNKNOWN_ATTRIBUTE,
database: Constants.UNKNOWN_ATTRIBUTE,
};
}
const regex =
/projects\/(?<projectId>[^/]+)\/instances\/(?<instanceId>[^/]+)(?:\/databases\/(?<databaseId>[^/]+))?/;
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};
}

Expand Down Expand Up @@ -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);
}

/**
Expand Down
102 changes: 90 additions & 12 deletions handwritten/spanner/src/metrics/metrics-tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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.',
);
Expand Down Expand Up @@ -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.',
);
Expand Down
63 changes: 63 additions & 0 deletions handwritten/spanner/test/metrics/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
let mockMetricsTracer: sinon.SinonStubbedInstance<MetricsTracer>;
let mockFactory: sinon.SinonStubbedInstance<MetricsTracerFactory>;
let mockNextCall: sinon.SinonStub;
let mockInterceptingCall: any;

Check warning on line 28 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let mockListener: any;

Check warning on line 29 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let serverTimingMetadata: any;

Check warning on line 30 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let emptyMetadata: any;

Check warning on line 31 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let mockStatus: any;

Check warning on line 32 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let mockOptions: any;

Check warning on line 33 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let capturedListener: any;

Check warning on line 34 in handwritten/spanner/test/metrics/interceptor.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
let testMetadata: grpc.Metadata;

beforeEach(() => {
Expand All @@ -50,6 +50,9 @@
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
Expand All @@ -58,6 +61,9 @@
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<
Expand Down Expand Up @@ -215,6 +221,63 @@
);
});

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,
);
});

it('reads server-timing header using metadata.get without calling metadata.getMap', () => {
const getMapSpy = sandbox.spy(serverTimingMetadata, 'getMap');
const getSpy = sandbox.spy(serverTimingMetadata, 'get');
Expand Down
Loading
Loading