Skip to content

Commit 82918c5

Browse files
cassandra-driver Instrumentation (#1280)
* Add cassandradriver to tox * Add initial cassandra instrumentation * Add cassandra testing * Refined instrumentation and tests * Cover multiple async reactors in tests * Separate async and sync tests * Provide cluster options as fixture * Add ORM Model tests for cqlengine * Add cassandra runner to Github Actions * Adjust cassandra tox env list * Add skip for pypy libev tests * Format trivy * Address suggestions from code review --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 019b019 commit 82918c5

File tree

8 files changed

+654
-27
lines changed

8 files changed

+654
-27
lines changed

.github/workflows/tests.yml

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
runs-on: ubuntu-latest
3535
needs:
3636
- python
37+
- cassandra
3738
- elasticsearchserver07
3839
- elasticsearchserver08
3940
- firestore
@@ -74,29 +75,29 @@ jobs:
7475

7576
- name: Run Trivy vulnerability scanner in repo mode
7677
if: ${{ github.event_name == 'pull_request' }}
77-
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
78+
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
7879
with:
79-
scan-type: 'fs'
80+
scan-type: "fs"
8081
ignore-unfixed: true
8182
format: table
8283
exit-code: 1
83-
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
84+
severity: "CRITICAL,HIGH,MEDIUM,LOW"
8485

8586
- name: Run Trivy vulnerability scanner in repo mode
8687
if: ${{ github.event_name == 'schedule' }}
87-
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
88+
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
8889
with:
89-
scan-type: 'fs'
90+
scan-type: "fs"
9091
ignore-unfixed: true
91-
format: 'sarif'
92-
output: 'trivy-results.sarif'
93-
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
92+
format: "sarif"
93+
output: "trivy-results.sarif"
94+
severity: "CRITICAL,HIGH,MEDIUM,LOW"
9495

9596
- name: Upload Trivy scan results to GitHub Security tab
9697
if: ${{ github.event_name == 'schedule' }}
9798
uses: github/codeql-action/upload-sarif@v3
9899
with:
99-
sarif_file: 'trivy-results.sarif'
100+
sarif_file: "trivy-results.sarif"
100101

101102
# Combine and upload coverage data
102103
coverage:
@@ -274,7 +275,7 @@ jobs:
274275
- 8080:8080
275276
- 8081:8081
276277
- 8082:8082
277-
# Set health checks to wait until nginx has started
278+
# Set health checks to wait until container has started
278279
options: >-
279280
--health-cmd "service nginx status || exit 1"
280281
--health-interval 10s
@@ -338,7 +339,7 @@ jobs:
338339
ports:
339340
- 8080:5432
340341
- 8081:5432
341-
# Set health checks to wait until postgres has started
342+
# Set health checks to wait until container has started
342343
options: >-
343344
--health-cmd pg_isready
344345
--health-interval 10s
@@ -402,7 +403,7 @@ jobs:
402403
ports:
403404
- 8080:5432
404405
- 8081:5432
405-
# Set health checks to wait until postgres has started
406+
# Set health checks to wait until container has started
406407
options: >-
407408
--health-cmd pg_isready
408409
--health-interval 10s
@@ -469,7 +470,7 @@ jobs:
469470
ports:
470471
- 8080:1433
471472
- 8081:1433
472-
# Set health checks to wait until mysql has started
473+
# Set health checks to wait until container has started
473474
options: >-
474475
--health-cmd "/opt/mssql-tools/bin/sqlcmd -U SA -P $MSSQL_SA_PASSWORD -Q 'SELECT 1'"
475476
--health-interval 10s
@@ -536,7 +537,7 @@ jobs:
536537
ports:
537538
- 8080:3306
538539
- 8081:3306
539-
# Set health checks to wait until mysql has started
540+
# Set health checks to wait until container has started
540541
options: >-
541542
--health-cmd "mysqladmin ping -h localhost"
542543
--health-interval 10s
@@ -701,7 +702,7 @@ jobs:
701702
ports:
702703
- 8080:6379
703704
- 8081:6379
704-
# Set health checks to wait until redis has started
705+
# Set health checks to wait until container has started
705706
options: >-
706707
--health-cmd "redis-cli ping"
707708
--health-interval 10s
@@ -765,7 +766,7 @@ jobs:
765766
ports:
766767
- 8080:8983
767768
- 8081:8983
768-
# Set health checks to wait until solr has started
769+
# Set health checks to wait until container has started
769770
options: >-
770771
--health-cmd "curl localhost:8983/solr/collection/admin/ping | grep OK"
771772
--health-interval 10s
@@ -827,7 +828,7 @@ jobs:
827828
ports:
828829
- 8080:11211
829830
- 8081:11211
830-
# Set health checks to wait until memcached has started
831+
# Set health checks to wait until container has started
831832
options: >-
832833
--health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'"
833834
--health-interval 10s
@@ -890,7 +891,7 @@ jobs:
890891
RABBITMQ_PASSWORD: rabbitmq
891892
ports:
892893
- 5672:5672
893-
# Set health checks to wait until rabbitmq has started
894+
# Set health checks to wait until container has started
894895
options: >-
895896
--health-cmd "rabbitmq-diagnostics status"
896897
--health-interval 10s
@@ -1026,7 +1027,7 @@ jobs:
10261027
ports:
10271028
- 8080:27017
10281029
- 8081:27017
1029-
# Set health checks to wait until mongodb has started
1030+
# Set health checks to wait until container has started
10301031
options: >-
10311032
--health-cmd "echo 'db.runCommand(\"ping\").ok' | mongo localhost:27017/test --quiet || exit 1"
10321033
--health-interval 10s
@@ -1088,7 +1089,7 @@ jobs:
10881089
ports:
10891090
- 8080:27017
10901091
- 8081:27017
1091-
# Set health checks to wait until mongodb has started
1092+
# Set health checks to wait until container has started
10921093
options: >-
10931094
--health-cmd "echo 'db.runCommand(\"ping\").ok' | mongosh localhost:27017/test --quiet || exit 1"
10941095
--health-interval 10s
@@ -1129,6 +1130,73 @@ jobs:
11291130
path: ./**/.coverage.*
11301131
retention-days: 1
11311132

1133+
cassandra:
1134+
env:
1135+
TOTAL_GROUPS: 1
1136+
1137+
strategy:
1138+
fail-fast: false
1139+
matrix:
1140+
group-number: [1]
1141+
1142+
runs-on: ubuntu-latest
1143+
container:
1144+
image: ghcr.io/newrelic/newrelic-python-agent-ci:latest
1145+
options: >-
1146+
--add-host=host.docker.internal:host-gateway
1147+
timeout-minutes: 30
1148+
services:
1149+
cassandra:
1150+
image: cassandra:5.0.2
1151+
env:
1152+
CASSANDRA_SEEDS: "cassandra"
1153+
CASSANDRA_CLUSTER_NAME: TestCluster
1154+
CASSANDRA_ENDPOINT_SNITCH: SimpleSnitch
1155+
CASSANDRA_NUM_TOKENS: "128"
1156+
ports:
1157+
- 8080:9042
1158+
- 8081:9042
1159+
# Set health checks to wait until container has started
1160+
options: >-
1161+
--health-cmd "cqlsh localhost 9042 -e 'describe cluster'"
1162+
--health-interval 30s
1163+
--health-timeout 5s
1164+
--health-retries 10
1165+
1166+
steps:
1167+
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # 4.1.1
1168+
1169+
- name: Fetch git tags
1170+
run: |
1171+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
1172+
git fetch --tags origin
1173+
1174+
- name: Configure pip cache
1175+
run: |
1176+
mkdir -p /github/home/.cache/pip
1177+
chown -R $(whoami) /github/home/.cache/pip
1178+
1179+
- name: Get Environments
1180+
id: get-envs
1181+
run: |
1182+
echo "envs=$(tox -l | grep '^${{ github.job }}\-' | ./.github/workflows/get-envs.py)" >> $GITHUB_OUTPUT
1183+
env:
1184+
GROUP_NUMBER: ${{ matrix.group-number }}
1185+
1186+
- name: Test
1187+
run: |
1188+
tox -vv -e ${{ steps.get-envs.outputs.envs }} -p auto
1189+
env:
1190+
TOX_PARALLEL_NO_SPINNER: 1
1191+
PY_COLORS: 0
1192+
1193+
- name: Upload Coverage Artifacts
1194+
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # 4.3.1
1195+
with:
1196+
name: coverage-${{ github.job }}-${{ strategy.job-index }}
1197+
path: ./**/.coverage.*
1198+
retention-days: 1
1199+
11321200
elasticsearchserver07:
11331201
env:
11341202
TOTAL_GROUPS: 1
@@ -1152,7 +1220,7 @@ jobs:
11521220
ports:
11531221
- 8080:9200
11541222
- 8081:9200
1155-
# Set health checks to wait until elasticsearch has started
1223+
# Set health checks to wait until container has started
11561224
options: >-
11571225
--health-cmd "curl --silent --fail localhost:9200/_cluster/health || exit 1"
11581226
--health-interval 10s
@@ -1217,7 +1285,7 @@ jobs:
12171285
ports:
12181286
- 8080:9200
12191287
- 8081:9200
1220-
# Set health checks to wait until elasticsearch has started
1288+
# Set health checks to wait until container has started
12211289
options: >-
12221290
--health-cmd "curl --silent --fail localhost:9200/_cluster/health || exit 1"
12231291
--health-interval 10s
@@ -1279,7 +1347,7 @@ jobs:
12791347
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:437.0.1-emulators
12801348
ports:
12811349
- 8080:8080
1282-
# Set health checks to wait 5 seconds in lieu of an actual healthcheck
1350+
# Set health checks to wait until container has started
12831351
options: >-
12841352
--health-cmd "echo success"
12851353
--health-interval 10s
@@ -1348,7 +1416,7 @@ jobs:
13481416
ports:
13491417
- 8080:6379
13501418
- 8081:6379
1351-
# Set health checks to wait until valkey has started
1419+
# Set health checks to wait until container has started
13521420
options: >-
13531421
--health-cmd "valkey-cli ping"
13541422
--health-interval 10s

newrelic/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3164,6 +3164,11 @@ def _process_module_builtin_defaults():
31643164
"instrument_gunicorn_app_base",
31653165
)
31663166

3167+
_process_module_definition("cassandra", "newrelic.hooks.datastore_cassandradriver", "instrument_cassandra")
3168+
_process_module_definition(
3169+
"cassandra.cluster", "newrelic.hooks.datastore_cassandradriver", "instrument_cassandra_cluster"
3170+
)
3171+
31673172
_process_module_definition("cx_Oracle", "newrelic.hooks.database_cx_oracle", "instrument_cx_oracle")
31683173

31693174
_process_module_definition("ibm_db_dbi", "newrelic.hooks.database_ibm_db_dbi", "instrument_ibm_db_dbi")
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright 2010 New Relic, Inc.
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+
from newrelic.api.database_trace import DatabaseTrace, register_database_client
16+
from newrelic.api.function_trace import wrap_function_trace
17+
from newrelic.api.time_trace import current_trace
18+
from newrelic.common.object_wrapper import wrap_function_wrapper
19+
from newrelic.common.signature import bind_args
20+
21+
DBAPI2_MODULE = None
22+
DEFAULT = object()
23+
24+
25+
def wrap_Session_execute(wrapped, instance, args, kwargs):
26+
# Most of this wrapper is lifted from DBAPI2 wrappers, which can't be used
27+
# directly since Cassandra doesn't actually conform to DBAPI2.
28+
29+
trace = current_trace()
30+
if not trace or trace.terminal_node():
31+
# Exit early there's no transaction, or if we're under an existing DatabaseTrace
32+
return wrapped(*args, **kwargs)
33+
34+
bound_args = bind_args(wrapped, args, kwargs)
35+
36+
sql_parameters = bound_args.get("parameters", None)
37+
38+
sql = bound_args.get("query", None)
39+
if not isinstance(sql, str):
40+
statement = getattr(sql, "prepared_statement", sql) # Unbind BoundStatement
41+
sql = getattr(statement, "query_string", statement) # Unpack query from SimpleStatement and PreparedStatement
42+
43+
database_name = getattr(instance, "keyspace", None)
44+
45+
host = None
46+
port = None
47+
try:
48+
contact_points = instance.cluster.contact_points
49+
if len(contact_points) == 1:
50+
contact_point = next(iter(contact_points))
51+
if isinstance(contact_point, str):
52+
host = contact_point
53+
port = instance.cluster.port
54+
elif isinstance(contact_point, tuple):
55+
host, port = contact_point
56+
else: # Handle cassandra.connection.Endpoint types
57+
host = contact_point.address
58+
port = contact_point.port
59+
except Exception:
60+
pass
61+
62+
if sql_parameters is not DEFAULT:
63+
with DatabaseTrace(
64+
sql=sql,
65+
sql_parameters=sql_parameters,
66+
execute_params=(args, kwargs),
67+
host=host,
68+
port_path_or_id=port,
69+
database_name=database_name,
70+
dbapi2_module=DBAPI2_MODULE,
71+
source=wrapped,
72+
):
73+
return wrapped(*args, **kwargs)
74+
else:
75+
with DatabaseTrace(
76+
sql=sql,
77+
execute_params=(args, kwargs),
78+
host=host,
79+
port_path_or_id=port,
80+
database_name=database_name,
81+
dbapi2_module=DBAPI2_MODULE,
82+
source=wrapped,
83+
):
84+
return wrapped(*args, **kwargs)
85+
86+
87+
def instrument_cassandra(module):
88+
# Cassandra isn't DBAPI2 compliant, but we need the DatabaseTrace to function properly. We can set parameters
89+
# for CQL parsing and the product name here, and leave the explain plan functionality unused.
90+
global DBAPI2_MODULE
91+
DBAPI2_MODULE = module
92+
93+
register_database_client(
94+
module,
95+
database_product="Cassandra",
96+
quoting_style="single+double",
97+
explain_query=None,
98+
explain_stmts=(),
99+
instance_info=None, # Already handled in wrappers
100+
)
101+
102+
103+
def instrument_cassandra_cluster(module):
104+
if hasattr(module, "Session"):
105+
# Cluster connect instrumentation, normally supplied by DBAPI2ConnectionFactory
106+
wrap_function_trace(
107+
module, "Cluster.connect", terminal=True, rollup=["Datastore/all", "Datastore/Cassandra/all"]
108+
)
109+
110+
# Currently Session.execute() is a wrapper for calling Session.execute_async() and immediately waiting for
111+
# the result. We therefore need to instrument Session.execute() in order to get timing information for sync
112+
# query executions. We also need to instrument Session.execute_async() to at least get metrics for async
113+
# queries, but we can't get timing information from that alone. We also need to add an early exit condition
114+
# for when instrumentation for Session.execute_async() is called within Session.execute().
115+
wrap_function_wrapper(module, "Session.execute", wrap_Session_execute)
116+
117+
# This wrapper only provides metrics, and not proper timing for async queries as they are distributed across
118+
# potentially many threads at once. This is left uninstrumented for the time being.
119+
wrap_function_wrapper(module, "Session.execute_async", wrap_Session_execute)

0 commit comments

Comments
 (0)