diff --git a/README.md b/README.md index 7457ab6..a3395f3 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,13 @@ Library | Plugin Name | [`Express`](https://expressjs.com) | `express` | | [`Axios`](https://github.com/axios/axios) | `axios` | | [`MySQL`](https://github.com/mysqljs/mysql) | `mysql` | +| [`MySQL`](https://github.com/sidorares/node-mysql2) | `mysql2` | | [`PostgreSQL`](https://github.com/brianc/node-postgres) | `pg` | | [`pg-cursor`](https://github.com/brianc/node-postgres) | `pg-cursor` | | [`MongoDB`](https://github.com/mongodb/node-mongodb-native) | `mongodb` | | [`Mongoose`](https://github.com/Automattic/mongoose) | `mongoose` | | [`RabbitMQ`](https://github.com/squaremo/amqp.node) | `amqplib` | +| [`Redis`](https://github.com/luin/ioredis) | `ioredis` | ### Compatible Libraries diff --git a/package-lock.json b/package-lock.json index 844dadd..add91e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -953,6 +953,15 @@ "@types/node": "*" } }, + "@types/ioredis": { + "version": "4.26.4", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.4.tgz", + "integrity": "sha512-QFbjNq7EnOGw6d1gZZt2h26OFXjx7z+eqEnbCHSrDI1OOLEgOHMKdtIajJbuCr9uO+X9kQQRe7Lz6uxqxl5XKg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -2138,6 +2147,12 @@ "wrap-ansi": "^2.0.0" } }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", + "dev": true + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3846,6 +3861,41 @@ "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" }, + "ioredis": { + "version": "4.27.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.2.tgz", + "integrity": "sha512-7OpYymIthonkC2Jne5uGWXswdhlua1S1rWGAERaotn0hGJWTSURvxdHA9G6wNbT/qKCloCja/FHsfKXW8lpTmg==", + "dev": true, + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "p-map": "^2.1.0", + "redis-commands": "1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5234,6 +5284,18 @@ "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=" }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=", + "dev": true + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", + "dev": true + }, "lodash.template": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", @@ -6202,6 +6264,12 @@ "p-limit": "^2.2.0" } }, + "p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true + }, "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -6585,6 +6653,27 @@ "util-deprecate": "~1.0.1" } }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", + "dev": true + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "dev": true + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dev": true, + "requires": { + "redis-errors": "^1.0.0" + } + }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -7392,6 +7481,12 @@ } } }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "dev": true + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", diff --git a/package.json b/package.json index a05e96f..fa343d0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@types/express": "^4.17.9", "@types/google-protobuf": "^3.7.2", + "@types/ioredis": "^4.26.4", "@types/jest": "^26.0.15", "@types/node": "^14.0.11", "@types/semver": "^7.2.0", @@ -49,6 +50,7 @@ "express": "^4.17.1", "grpc-tools": "^1.10.0", "grpc_tools_node_protoc_ts": "^4.0.0", + "ioredis": "^4.27.2", "jest": "^26.6.3", "mongodb": "^3.6.4", "mongoose": "^5.12.2", diff --git a/src/plugins/IORedisPlugin.ts b/src/plugins/IORedisPlugin.ts new file mode 100644 index 0000000..adbd930 --- /dev/null +++ b/src/plugins/IORedisPlugin.ts @@ -0,0 +1,62 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + * + */ + +import PluginInstaller from '../core/PluginInstaller'; +import SwPlugin, { wrapPromise } from '../core/SwPlugin'; +import { SpanLayer } from '../proto/language-agent/Tracing_pb'; +import Tag from '../Tag'; +import { Component } from '../trace/Component'; +import ContextManager from '../trace/context/ContextManager'; + +class IORedisPlugin implements SwPlugin { + readonly module = 'ioredis'; + readonly versions = '*'; + + install(installer: PluginInstaller): void { + const Redis = installer.require('ioredis'); + + this.interceptOperation(Redis, 'sendCommand'); + } + + interceptOperation(Cls: any, operation: string): void { + const _original = Cls.prototype[operation]; + + if (!_original) + return; + + Cls.prototype[operation] = function (...args: any[]) { + const command = args[0]; + const host = `${this.options.host}:${this.options.port}`; + const span = ContextManager.current.newExitSpan(`redis/${command?.name}`, Component.REDIS); + + span.start(); + span.component = Component.REDIS; + span.layer = SpanLayer.CACHE; + span.peer = host; + span.tag(Tag.dbType('Redis')); + span.tag(Tag.dbInstance(`${this.condition.select}`)); + + const ret = wrapPromise(span, _original.apply(this, args)); + span.async(); + return ret; + } + } +} + +export default new IORedisPlugin(); \ No newline at end of file diff --git a/src/trace/Component.ts b/src/trace/Component.ts index f45bf0d..a4bd44f 100644 --- a/src/trace/Component.ts +++ b/src/trace/Component.ts @@ -21,6 +21,7 @@ export class Component { static readonly UNKNOWN = new Component(0); static readonly HTTP = new Component(2); static readonly MYSQL = new Component(5); + static readonly REDIS = new Component(7); static readonly MONGODB = new Component(9); static readonly POSTGRESQL = new Component(22); static readonly HTTP_SERVER = new Component(49); @@ -31,5 +32,5 @@ export class Component { static readonly AXIOS = new Component(4005); static readonly MONGOOSE = new Component(4006); - constructor(public readonly id: number) {} + constructor(public readonly id: number) { } } diff --git a/tests/plugins/ioredis/client.ts b/tests/plugins/ioredis/client.ts new file mode 100644 index 0000000..2f33af1 --- /dev/null +++ b/tests/plugins/ioredis/client.ts @@ -0,0 +1,38 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + * + */ + +import * as http from 'http'; +import agent from '../../../src'; + +agent.start({ + serviceName: 'client', + maxBufferSize: 1000, +}) + +const server = http.createServer((req, res) => { + http + .request(`http://${process.env.SERVER || 'localhost:5000'}${req.url}`, (r) => { + let data = ''; + r.on('data', (chunk) => (data += chunk)); + r.on('end', () => res.end(data)); + }) + .end(); +}); + +server.listen(5001, () => console.info('Listening on port 5001...')); diff --git a/tests/plugins/ioredis/docker-compose.yml b/tests/plugins/ioredis/docker-compose.yml new file mode 100644 index 0000000..f66fb04 --- /dev/null +++ b/tests/plugins/ioredis/docker-compose.yml @@ -0,0 +1,84 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +version: "2.1" + +services: + collector: + extends: + file: ../common/base-compose.yml + service: collector + networks: + - traveling-light + + redis: + container_name: redis + ports: + - 6379:6379 + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/6379"] + interval: 5s + timeout: 60s + retries: 120 + image: "redis:latest" + networks: + - traveling-light + + server: + extends: + file: ../common/base-compose.yml + service: agent + ports: + - 5000:5000 + environment: + REDIS_HOST: redis + volumes: + - .:/app/tests/plugins/ioredis + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/5000"] + interval: 5s + timeout: 60s + retries: 120 + entrypoint: + ["bash", "-c", "npx ts-node /app/tests/plugins/ioredis/server.ts"] + depends_on: + collector: + condition: service_healthy + redis: + condition: service_healthy + + client: + extends: + file: ../common/base-compose.yml + service: agent + ports: + - 5001:5001 + environment: + SERVER: server:5000 + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/5001"] + interval: 5s + timeout: 60s + retries: 120 + entrypoint: + ["bash", "-c", "npx ts-node /app/tests/plugins/ioredis/client.ts"] + depends_on: + server: + condition: service_healthy + +networks: + traveling-light: diff --git a/tests/plugins/ioredis/expected.data.yaml b/tests/plugins/ioredis/expected.data.yaml new file mode 100644 index 0000000..d527352 --- /dev/null +++ b/tests/plugins/ioredis/expected.data.yaml @@ -0,0 +1,131 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +segmentItems: + - serviceName: server + segmentSize: 2 + segments: + - segmentId: not null + spans: + - operationName: redis/info + operationId: 0 + parentSpanId: -1 + spanId: 0 + spanLayer: Cache + startTime: gt 0 + endTime: gt 0 + componentId: 7 + spanType: Exit + peer: redis:6379 + skipAnalysis: false + tags: + - { key: coldStart, value: "true" } + - { key: db.type, value: Redis } + - { key: db.instance, value: "0" } + - segmentId: not null + spans: + - operationName: redis/set + operationId: 0 + parentSpanId: 0 + spanId: 1 + spanLayer: Cache + startTime: gt 0 + endTime: gt 0 + componentId: 7 + spanType: Exit + peer: redis:6379 + skipAnalysis: false + tags: + - { key: db.type, value: Redis } + - { key: db.instance, value: "0" } + - operationName: redis/get + operationId: 0 + parentSpanId: 0 + spanId: 2 + spanLayer: Cache + startTime: gt 0 + endTime: gt 0 + componentId: 7 + spanType: Exit + peer: redis:6379 + skipAnalysis: false + tags: + - { key: db.type, value: Redis } + - { key: db.instance, value: "0" } + - operationName: /ioredis + operationId: 0 + parentSpanId: -1 + spanId: 0 + spanLayer: Http + startTime: gt 0 + endTime: gt 0 + componentId: 49 + spanType: Entry + peer: not null + skipAnalysis: false + tags: + - { key: http.url, value: "http://server:5000/ioredis" } + - { key: http.method, value: GET } + - { key: http.status.code, value: "200" } + - { key: http.status.msg, value: OK } + refs: + - parentEndpoint: "" + networkAddress: server:5000 + refType: CrossProcess + parentSpanId: 1 + parentTraceSegmentId: not null + parentServiceInstance: not null + parentService: client + traceId: not null + - serviceName: client + segmentSize: 1 + segments: + - segmentId: not null + spans: + - operationName: /ioredis + operationId: 0 + parentSpanId: -1 + spanId: 0 + spanLayer: Http + startTime: gt 0 + endTime: gt 0 + componentId: 49 + spanType: Entry + peer: not null + skipAnalysis: false + tags: + - { key: coldStart, value: "true" } + - { key: http.url, value: "http://localhost:5001/ioredis" } + - { key: http.method, value: GET } + - { key: http.status.code, value: "200" } + - { key: http.status.msg, value: OK } + - operationName: /ioredis + operationId: 0 + parentSpanId: 0 + spanId: 1 + spanLayer: Http + startTime: gt 0 + endTime: gt 0 + componentId: 2 + spanType: Exit + peer: server:5000 + skipAnalysis: false + tags: + - { key: http.url, value: "http://server:5000/ioredis" } + - { key: http.method, value: GET } + - { key: http.status.code, value: "200" } + - { key: http.status.msg, value: OK } diff --git a/tests/plugins/ioredis/server.ts b/tests/plugins/ioredis/server.ts new file mode 100644 index 0000000..cf56507 --- /dev/null +++ b/tests/plugins/ioredis/server.ts @@ -0,0 +1,50 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + * + */ + +import * as http from 'http'; +import Redis from 'ioredis'; +import agent from '../../../src'; +import assert from 'assert'; + +agent.start({ + serviceName: 'server', + maxBufferSize: 1000, +}); + +const client = new Redis({ + host: process.env.REDIS_HOST || 'redis', +}); + +const server = http.createServer((req, res) => { + (async () => { + const cacheKey = 'now'; + const now = '' + Date.now(); + + await client.set(cacheKey, now); + const _now = await client.get(cacheKey); + assert.strictEqual(now, _now); + + res.end(_now); + })().catch((err: Error) => { + res.statusCode = 500; + res.end(err.message); + }); +}) + +server.listen(5000, () => console.info('Listening on port 5000...')); diff --git a/tests/plugins/ioredis/test.ts b/tests/plugins/ioredis/test.ts new file mode 100644 index 0000000..8a8b423 --- /dev/null +++ b/tests/plugins/ioredis/test.ts @@ -0,0 +1,57 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + * + */ + +import * as path from 'path'; +import { DockerComposeEnvironment, StartedDockerComposeEnvironment, Wait } from 'testcontainers'; +import axios from 'axios'; +import waitForExpect from 'wait-for-expect'; +import { promises as fs } from 'fs'; + +const rootDir = path.resolve(__dirname); + +describe('plugin tests', () => { + let compose: StartedDockerComposeEnvironment; + + beforeAll(async () => { + compose = await new DockerComposeEnvironment(rootDir, 'docker-compose.yml') + .withWaitStrategy('client', Wait.forHealthCheck()) + .withWaitStrategy('redis', Wait.forHealthCheck()) + .up(); + }); + + afterAll(async () => { + await compose.down(); + }); + + it(__filename, async () => { + await waitForExpect(async () => expect((await axios.get('http://localhost:5001/ioredis')).status).toBe(200)); + + const expectedData = await fs.readFile(path.join(rootDir, 'expected.data.yaml'), 'utf8'); + + try { + await waitForExpect(async () => + expect((await axios.post('http://localhost:12800/dataValidate', expectedData)).status).toBe(200), + ); + } catch (e) { + const actualData = (await axios.get('http://localhost:12800/receiveData')).data; + console.info({ actualData }); + throw e; + } + }); +});