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
36 changes: 36 additions & 0 deletions memorystore/redis/client_side_metrics/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "memorystore-redis-client-side-metrics",
"description": "An example of using Memorystore (Redis) with OpenTelemetry for client-side metrics and tracing in Node.js",
"version": "0.0.1",
"private": true,
"license": "Apache-2.0",
"author": "Google Inc.",
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git"
},
"scripts": {
"test": "mocha test/**/*.js"
},
"dependencies": {
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/instrumentation": "^0.205.0",
"@opentelemetry/instrumentation-redis": "^0.67.0",
"@opentelemetry/resources": "^2.1.0",
"@opentelemetry/sdk-metrics": "^2.1.0",
"@opentelemetry/sdk-trace-base": "^2.1.0",
"@opentelemetry/sdk-trace-node": "^2.1.0",
"redis": "^4.6.0"
},
"devDependencies": {
"mocha": "^10.0.0",
"chai": "^4.3.0",
"proxyquire": "^2.1.0",
"sinon": "^15.0.0"
}
}
156 changes: 156 additions & 0 deletions memorystore/redis/client_side_metrics/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Copyright 2026 Google, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// [START memorystore_redis_client_side_metrics]

'use strict';

const {trace, metrics} = require('@opentelemetry/api');
const {NodeTracerProvider} = require('@opentelemetry/sdk-trace-node');
const {BatchSpanProcessor} = require('@opentelemetry/sdk-trace-base');
const {
TraceExporter,
} = require('@google-cloud/opentelemetry-cloud-trace-exporter');
const {
MeterProvider,
PeriodicExportingMetricReader,
} = require('@opentelemetry/sdk-metrics');
const {
MetricExporter,
} = require('@google-cloud/opentelemetry-cloud-monitoring-exporter');
const {RedisInstrumentation} = require('@opentelemetry/instrumentation-redis');
const {registerInstrumentations} = require('@opentelemetry/instrumentation');
const {performance} = require('perf_hooks');

// FIX: Pass spanProcessors in the constructor options for NodeTracerProvider in SDK 2.x
const provider = new NodeTracerProvider({
spanProcessors: [new BatchSpanProcessor(new TraceExporter())],
});
provider.register();

registerInstrumentations({
instrumentations: [new RedisInstrumentation()],
});

const redis = require('redis');

const metricExporter = new MetricExporter();
const metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 10000,
});
const meterProvider = new MeterProvider({readers: [metricReader]});
metrics.setGlobalMeterProvider(meterProvider);

const tracer = trace.getTracer('redis.client.node');
const meter = metrics.getMeter('redis.metrics.node');

const rttHist = meter.createHistogram('redis_client_rtt', {unit: 'ms'});
const appBlockHist = meter.createHistogram(
'redis_application_blocking_latency',
{unit: 'ms'}
);
const retryCounter = meter.createCounter('redis_retry_count');
const connErrorCounter = meter.createCounter('redis_connectivity_error_count');

retryCounter.add(0, {operation: 'startup'});
connErrorCounter.add(0, {operation: 'startup'});

const REDISHOST = process.env.REDISHOST || 'localhost';
const REDISPORT = process.env.REDISPORT || 6379;

const client = redis.createClient({
socket: {
host: REDISHOST,
port: REDISPORT,
reconnectStrategy: retries => {
connErrorCounter.add(1, {error: 'socket_reconnect'});
if (retries > 5) return new Error('Max retries reached');
return Math.min(retries * 100, 3000);
},
},
});
client.on('error', err => console.log('Redis Client Error', err));

async function smartRedisCall(operationName, func, ...args) {
let attempt = 0;
while (attempt < 3) {
try {
const reqStart = performance.now();
const response = await func(...args);
rttHist.record(performance.now() - reqStart, {operation: operationName});

const appParseStart = performance.now();
// eslint-disable-next-line no-unused-vars
const _ = String(response);
Comment thread
fosky94 marked this conversation as resolved.
appBlockHist.record(performance.now() - appParseStart, {
operation: operationName,
});

return response;
} catch (e) {
attempt++;
retryCounter.add(1, {operation: operationName});
if (attempt >= 3) throw e;
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 100)
);
}
}
}

async function main() {
await client.connect();

await tracer.startActiveSpan('process_user_span', async span => {
try {
// Simple write and read operations
await smartRedisCall(
'set_user',
client.set.bind(client),
'user:123',
'active'
);

const result = await smartRedisCall(
'get_user',
client.get.bind(client),
'user:123'
);
console.log('Retrieved:', result);
} catch (e) {
span.recordException(e);
} finally {
span.end();
}
});

await client.quit();
await provider.forceFlush();
await meterProvider.forceFlush();
}

// Only run the script automatically if it is executed directly (e.g. `node server.js`)
if (require.main === module) {
main().catch(console.error);
}

// Export for testability
module.exports = {
main,
smartRedisCall,
};

// [END memorystore_redis_client_side_metrics]
140 changes: 140 additions & 0 deletions memorystore/redis/client_side_metrics/test/server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict';

const proxyquire = require('proxyquire');
const sinon = require('sinon');
const {assert} = require('chai');

describe('Memorystore Redis Client-Side Metrics Sample', () => {
it('should run successfully with mocked Redis and GCP exporters', async () => {
// Stubs for Redis Client
const clientStub = {
connect: sinon.stub().resolves(),
set: sinon.stub().resolves('OK'),
get: sinon.stub().resolves('active'),
on: sinon.stub(),
quit: sinon.stub().resolves(),
};

const redisMock = {
createClient: sinon.stub().returns(clientStub),
};

// Mocks for GCP Exporters to avoid needing live GCP credentials in tests
class MockTraceExporter {
export(spans, resultCallback) {
if (resultCallback) resultCallback({code: 0});
}
shutdown() {
return Promise.resolve();
}
forceFlush() {
return Promise.resolve();
}
}

class MockMetricExporter {
export(metrics, resultCallback) {
if (resultCallback) resultCallback({code: 0});
}
shutdown() {
return Promise.resolve();
}
forceFlush() {
return Promise.resolve();
}
}

const traceExporterMock = {
TraceExporter: sinon.stub().returns(new MockTraceExporter()),
};

const metricExporterMock = {
MetricExporter: sinon.stub().returns(new MockMetricExporter()),
};

// Mock for Redis Instrumentation to prevent it trying to patch the mocked Redis module
class MockRedisInstrumentation {
enable() {}
disable() {}
setTracerProvider() {}
setMeterProvider() {}
getConfig() {
return {};
}
setConfig() {}
getInstrumentationName() {
return 'mock-redis';
}
getInstrumentationVersion() {
return '0.0.0';
}
init() {}
}

const redisInstrumentationMock = {
RedisInstrumentation: MockRedisInstrumentation,
};

// Load the sample with proxyquire, substituting our mocks for its top-level requires
const {main} = proxyquire('../server.js', {
redis: redisMock,
'@google-cloud/opentelemetry-cloud-trace-exporter': traceExporterMock,
'@google-cloud/opentelemetry-cloud-monitoring-exporter':
metricExporterMock,
'@opentelemetry/instrumentation-redis': redisInstrumentationMock,
});

// Verify top-level code executed as expected during the initial load phase
assert.isTrue(
redisMock.createClient.calledOnce,
'redis.createClient should be called during load'
);
assert.isTrue(
clientStub.on.calledWith('error', sinon.match.func),
'client.on("error", ...) should be called during load'
);
assert.isTrue(
traceExporterMock.TraceExporter.calledOnce,
'TraceExporter should be instantiated during load'
);
assert.isTrue(
metricExporterMock.MetricExporter.calledOnce,
'MetricExporter should be instantiated during load'
);

// Run the main function and await its completion
await main();

// Verify main() interactions
assert.isTrue(
clientStub.connect.calledOnce,
'client.connect should be called in main()'
);
assert.isTrue(
clientStub.set.calledOnce,
'client.set should be called in main() (via smartRedisCall)'
);
assert.isTrue(
clientStub.get.calledOnce,
'client.get should be called in main() (via smartRedisCall)'
);
assert.isTrue(
clientStub.quit.calledOnce,
'client.quit should be called in main()'
);
});
});
Loading