Skip to content

Commit 1d8c421

Browse files
fosky94angelcaamal
andauthored
feat(redis): create client side metrics for redis (GoogleCloudPlatform#4319)
* Create Client side metrics for redis * Update license * feat(redis): create client side metrics for redis * fix:fixing missing dependencies and add tests * style:fix gts style and formating errors --------- Co-authored-by: Angel Caamal <acaamalcanul@google.com>
1 parent e29d194 commit 1d8c421

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "memorystore-redis-client-side-metrics",
3+
"description": "An example of using Memorystore (Redis) with OpenTelemetry for client-side metrics and tracing in Node.js",
4+
"version": "0.0.1",
5+
"private": true,
6+
"license": "Apache-2.0",
7+
"author": "Google Inc.",
8+
"engines": {
9+
"node": ">=18.0.0"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "git+https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git"
14+
},
15+
"scripts": {
16+
"test": "mocha test/**/*.js"
17+
},
18+
"dependencies": {
19+
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
20+
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
21+
"@opentelemetry/api": "^1.9.0",
22+
"@opentelemetry/instrumentation": "^0.205.0",
23+
"@opentelemetry/instrumentation-redis": "^0.67.0",
24+
"@opentelemetry/resources": "^2.1.0",
25+
"@opentelemetry/sdk-metrics": "^2.1.0",
26+
"@opentelemetry/sdk-trace-base": "^2.1.0",
27+
"@opentelemetry/sdk-trace-node": "^2.1.0",
28+
"redis": "^4.6.0"
29+
},
30+
"devDependencies": {
31+
"mocha": "^10.0.0",
32+
"chai": "^4.3.0",
33+
"proxyquire": "^2.1.0",
34+
"sinon": "^15.0.0"
35+
}
36+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Copyright 2026 Google, Inc.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
// [START memorystore_redis_client_side_metrics]
17+
18+
'use strict';
19+
20+
const {trace, metrics} = require('@opentelemetry/api');
21+
const {NodeTracerProvider} = require('@opentelemetry/sdk-trace-node');
22+
const {BatchSpanProcessor} = require('@opentelemetry/sdk-trace-base');
23+
const {
24+
TraceExporter,
25+
} = require('@google-cloud/opentelemetry-cloud-trace-exporter');
26+
const {
27+
MeterProvider,
28+
PeriodicExportingMetricReader,
29+
} = require('@opentelemetry/sdk-metrics');
30+
const {
31+
MetricExporter,
32+
} = require('@google-cloud/opentelemetry-cloud-monitoring-exporter');
33+
const {RedisInstrumentation} = require('@opentelemetry/instrumentation-redis');
34+
const {registerInstrumentations} = require('@opentelemetry/instrumentation');
35+
const {performance} = require('perf_hooks');
36+
37+
// FIX: Pass spanProcessors in the constructor options for NodeTracerProvider in SDK 2.x
38+
const provider = new NodeTracerProvider({
39+
spanProcessors: [new BatchSpanProcessor(new TraceExporter())],
40+
});
41+
provider.register();
42+
43+
registerInstrumentations({
44+
instrumentations: [new RedisInstrumentation()],
45+
});
46+
47+
const redis = require('redis');
48+
49+
const metricExporter = new MetricExporter();
50+
const metricReader = new PeriodicExportingMetricReader({
51+
exporter: metricExporter,
52+
exportIntervalMillis: 10000,
53+
});
54+
const meterProvider = new MeterProvider({readers: [metricReader]});
55+
metrics.setGlobalMeterProvider(meterProvider);
56+
57+
const tracer = trace.getTracer('redis.client.node');
58+
const meter = metrics.getMeter('redis.metrics.node');
59+
60+
const rttHist = meter.createHistogram('redis_client_rtt', {unit: 'ms'});
61+
const appBlockHist = meter.createHistogram(
62+
'redis_application_blocking_latency',
63+
{unit: 'ms'}
64+
);
65+
const retryCounter = meter.createCounter('redis_retry_count');
66+
const connErrorCounter = meter.createCounter('redis_connectivity_error_count');
67+
68+
retryCounter.add(0, {operation: 'startup'});
69+
connErrorCounter.add(0, {operation: 'startup'});
70+
71+
const REDISHOST = process.env.REDISHOST || 'localhost';
72+
const REDISPORT = process.env.REDISPORT || 6379;
73+
74+
const client = redis.createClient({
75+
socket: {
76+
host: REDISHOST,
77+
port: REDISPORT,
78+
reconnectStrategy: retries => {
79+
connErrorCounter.add(1, {error: 'socket_reconnect'});
80+
if (retries > 5) return new Error('Max retries reached');
81+
return Math.min(retries * 100, 3000);
82+
},
83+
},
84+
});
85+
client.on('error', err => console.log('Redis Client Error', err));
86+
87+
async function smartRedisCall(operationName, func, ...args) {
88+
let attempt = 0;
89+
while (attempt < 3) {
90+
try {
91+
const reqStart = performance.now();
92+
const response = await func(...args);
93+
rttHist.record(performance.now() - reqStart, {operation: operationName});
94+
95+
const appParseStart = performance.now();
96+
// eslint-disable-next-line no-unused-vars
97+
const _ = String(response);
98+
appBlockHist.record(performance.now() - appParseStart, {
99+
operation: operationName,
100+
});
101+
102+
return response;
103+
} catch (e) {
104+
attempt++;
105+
retryCounter.add(1, {operation: operationName});
106+
if (attempt >= 3) throw e;
107+
await new Promise(resolve =>
108+
setTimeout(resolve, Math.pow(2, attempt) * 100)
109+
);
110+
}
111+
}
112+
}
113+
114+
async function main() {
115+
await client.connect();
116+
117+
await tracer.startActiveSpan('process_user_span', async span => {
118+
try {
119+
// Simple write and read operations
120+
await smartRedisCall(
121+
'set_user',
122+
client.set.bind(client),
123+
'user:123',
124+
'active'
125+
);
126+
127+
const result = await smartRedisCall(
128+
'get_user',
129+
client.get.bind(client),
130+
'user:123'
131+
);
132+
console.log('Retrieved:', result);
133+
} catch (e) {
134+
span.recordException(e);
135+
} finally {
136+
span.end();
137+
}
138+
});
139+
140+
await client.quit();
141+
await provider.forceFlush();
142+
await meterProvider.forceFlush();
143+
}
144+
145+
// Only run the script automatically if it is executed directly (e.g. `node server.js`)
146+
if (require.main === module) {
147+
main().catch(console.error);
148+
}
149+
150+
// Export for testability
151+
module.exports = {
152+
main,
153+
smartRedisCall,
154+
};
155+
156+
// [END memorystore_redis_client_side_metrics]
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict';
16+
17+
const proxyquire = require('proxyquire');
18+
const sinon = require('sinon');
19+
const {assert} = require('chai');
20+
21+
describe('Memorystore Redis Client-Side Metrics Sample', () => {
22+
it('should run successfully with mocked Redis and GCP exporters', async () => {
23+
// Stubs for Redis Client
24+
const clientStub = {
25+
connect: sinon.stub().resolves(),
26+
set: sinon.stub().resolves('OK'),
27+
get: sinon.stub().resolves('active'),
28+
on: sinon.stub(),
29+
quit: sinon.stub().resolves(),
30+
};
31+
32+
const redisMock = {
33+
createClient: sinon.stub().returns(clientStub),
34+
};
35+
36+
// Mocks for GCP Exporters to avoid needing live GCP credentials in tests
37+
class MockTraceExporter {
38+
export(spans, resultCallback) {
39+
if (resultCallback) resultCallback({code: 0});
40+
}
41+
shutdown() {
42+
return Promise.resolve();
43+
}
44+
forceFlush() {
45+
return Promise.resolve();
46+
}
47+
}
48+
49+
class MockMetricExporter {
50+
export(metrics, resultCallback) {
51+
if (resultCallback) resultCallback({code: 0});
52+
}
53+
shutdown() {
54+
return Promise.resolve();
55+
}
56+
forceFlush() {
57+
return Promise.resolve();
58+
}
59+
}
60+
61+
const traceExporterMock = {
62+
TraceExporter: sinon.stub().returns(new MockTraceExporter()),
63+
};
64+
65+
const metricExporterMock = {
66+
MetricExporter: sinon.stub().returns(new MockMetricExporter()),
67+
};
68+
69+
// Mock for Redis Instrumentation to prevent it trying to patch the mocked Redis module
70+
class MockRedisInstrumentation {
71+
enable() {}
72+
disable() {}
73+
setTracerProvider() {}
74+
setMeterProvider() {}
75+
getConfig() {
76+
return {};
77+
}
78+
setConfig() {}
79+
getInstrumentationName() {
80+
return 'mock-redis';
81+
}
82+
getInstrumentationVersion() {
83+
return '0.0.0';
84+
}
85+
init() {}
86+
}
87+
88+
const redisInstrumentationMock = {
89+
RedisInstrumentation: MockRedisInstrumentation,
90+
};
91+
92+
// Load the sample with proxyquire, substituting our mocks for its top-level requires
93+
const {main} = proxyquire('../server.js', {
94+
redis: redisMock,
95+
'@google-cloud/opentelemetry-cloud-trace-exporter': traceExporterMock,
96+
'@google-cloud/opentelemetry-cloud-monitoring-exporter':
97+
metricExporterMock,
98+
'@opentelemetry/instrumentation-redis': redisInstrumentationMock,
99+
});
100+
101+
// Verify top-level code executed as expected during the initial load phase
102+
assert.isTrue(
103+
redisMock.createClient.calledOnce,
104+
'redis.createClient should be called during load'
105+
);
106+
assert.isTrue(
107+
clientStub.on.calledWith('error', sinon.match.func),
108+
'client.on("error", ...) should be called during load'
109+
);
110+
assert.isTrue(
111+
traceExporterMock.TraceExporter.calledOnce,
112+
'TraceExporter should be instantiated during load'
113+
);
114+
assert.isTrue(
115+
metricExporterMock.MetricExporter.calledOnce,
116+
'MetricExporter should be instantiated during load'
117+
);
118+
119+
// Run the main function and await its completion
120+
await main();
121+
122+
// Verify main() interactions
123+
assert.isTrue(
124+
clientStub.connect.calledOnce,
125+
'client.connect should be called in main()'
126+
);
127+
assert.isTrue(
128+
clientStub.set.calledOnce,
129+
'client.set should be called in main() (via smartRedisCall)'
130+
);
131+
assert.isTrue(
132+
clientStub.get.calledOnce,
133+
'client.get should be called in main() (via smartRedisCall)'
134+
);
135+
assert.isTrue(
136+
clientStub.quit.calledOnce,
137+
'client.quit should be called in main()'
138+
);
139+
});
140+
});

0 commit comments

Comments
 (0)