diff --git a/src/workerd/api/BUILD.bazel b/src/workerd/api/BUILD.bazel index fec6c94864d..c4097b2faf5 100644 --- a/src/workerd/api/BUILD.bazel +++ b/src/workerd/api/BUILD.bazel @@ -498,7 +498,10 @@ wd_test( wd_test( src = "sync-kv-test.wd-test", args = ["--experimental"], - data = ["sync-kv-test.js"], + data = [ + "sync-kv-instrumentation-test.js", + "sync-kv-test.js", + ], ) wd_test( diff --git a/src/workerd/api/sync-kv-instrumentation-test.js b/src/workerd/api/sync-kv-instrumentation-test.js new file mode 100644 index 00000000000..c17d4e6f1c6 --- /dev/null +++ b/src/workerd/api/sync-kv-instrumentation-test.js @@ -0,0 +1,230 @@ +// Copyright (c) 2017-2023 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import * as assert from 'node:assert'; + +// tailStream is going to be invoked multiple times, but we want to wait +// to run the test until all executions are done. Collect promises for +// each +let invocationPromises = []; +let spans = new Map(); + +export default { + tailStream(event, env, ctx) { + // For each "onset" event, store a promise which we will resolve when + // we receive the equivalent "outcome" event + let resolveFn; + invocationPromises.push( + new Promise((resolve, reject) => { + resolveFn = resolve; + }) + ); + + // Accumulate the span info for easier testing + return (event) => { + // span ids are simple counters for tests, but invocation ID allows us to differentiate them + let spanKey = event.invocationId + event.spanContext.spanId; + switch (event.event.type) { + case 'spanOpen': + spans.set(event.invocationId + event.event.spanId, { + name: event.event.name, + }); + break; + case 'attributes': { + let span = spans.get(spanKey); + if (span) { + for (let { name, value } of event.event.info) { + span[name] = value; + } + spans.set(spanKey, span); + } + break; + } + case 'spanClose': { + let span = spans.get(spanKey); + span['closed'] = true; + spans.set(spanKey, span); + break; + } + case 'outcome': + resolveFn(); + break; + } + }; + }, +}; + +export const test = { + async test() { + // Wait for all the tailStream executions to finish + await Promise.allSettled(invocationPromises); + + // Recorded streaming tail worker events, in insertion order. + let received = Array.from(spans.values()); + + assert.deepStrictEqual(received, expectedSpans); + }, +}; + +const expectedSpans = [ + { + name: 'durable_object_storage_kv_get', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'get', + 'cloudflare.durable_object.kv.query.keys': 'foo', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_put', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'put', + 'cloudflare.durable_object.kv.query.keys': 'foo', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_get', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'get', + 'cloudflare.durable_object.kv.query.keys': 'foo', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_get', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'get', + 'cloudflare.durable_object.kv.query.keys': 'bar', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_put', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'put', + 'cloudflare.durable_object.kv.query.keys': 'bar', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_get', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'get', + 'cloudflare.durable_object.kv.query.keys': 'bar', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.reverse': true, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + closed: true, + }, + { + name: 'durable_object_storage_kv_put', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'put', + 'cloudflare.durable_object.kv.query.keys': 'baz', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.prefix': 'ba', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.limit': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.reverse': true, + 'cloudflare.durable_object.kv.query.limit': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.start': 'b', + 'cloudflare.durable_object.kv.query.end': 'c', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.start': 'b', + 'cloudflare.durable_object.kv.query.end': 'baz', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.start': 'bar', + 'cloudflare.durable_object.kv.query.end': 'c', + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + 'cloudflare.durable_object.kv.query.startAfter': 'bar', + 'cloudflare.durable_object.kv.query.end': 'c', + closed: true, + }, + { + name: 'durable_object_storage_kv_delete', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'delete', + 'cloudflare.durable_object.kv.query.keys': 'qux', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + 'cloudflare.durable_object.kv.response.deleted_count': 0n, + closed: true, + }, + { + name: 'durable_object_storage_kv_delete', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'delete', + 'cloudflare.durable_object.kv.query.keys': 'bar', + 'cloudflare.durable_object.kv.query.keys.count': 1n, + 'cloudflare.durable_object.kv.response.deleted_count': 1n, + closed: true, + }, + { + name: 'durable_object_storage_kv_list', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'list', + closed: true, + }, + { name: 'durable_object_subrequest', closed: true }, +]; diff --git a/src/workerd/api/sync-kv-test.wd-test b/src/workerd/api/sync-kv-test.wd-test index 5eedc1a219b..e233bfedb46 100644 --- a/src/workerd/api/sync-kv-test.wd-test +++ b/src/workerd/api/sync-kv-test.wd-test @@ -4,13 +4,16 @@ const config :Workerd.Config = ( services = [ (name = "main", worker = .mainWorker), (name = "TEST_TMPDIR", disk = (writable = true)), + (name = "tail", worker= .tailWorker) ], ); const mainWorker :Workerd.Worker = ( compatibilityDate = "2025-08-01", - compatibilityFlags = ["enable_ctx_exports", "nodejs_compat"], + compatibilityFlags = ["enable_ctx_exports", "nodejs_compat", "streaming_tail_worker", "tail_worker_user_spans"], + + streamingTails = ["tail"], modules = [ (name = "worker", esModule = embed "sync-kv-test.js"), @@ -24,3 +27,11 @@ const mainWorker :Workerd.Worker = ( durableObjectStorage = (localDisk = "TEST_TMPDIR"), ); + +const tailWorker :Workerd.Worker = ( + modules = [ + (name = "worker", esModule = embed "sync-kv-instrumentation-test.js") + ], + compatibilityDate = "2024-10-14", + compatibilityFlags = ["experimental", "nodejs_compat"], +); diff --git a/src/workerd/api/sync-kv.c++ b/src/workerd/api/sync-kv.c++ index fe21f445848..6bf1a986207 100644 --- a/src/workerd/api/sync-kv.c++ +++ b/src/workerd/api/sync-kv.c++ @@ -14,6 +14,11 @@ jsg::JsValue SyncKvStorage::get(jsg::Lock& js, kj::String key) { auto userSpan = IoContext::current().makeUserTraceSpan("durable_object_storage_kv_get"_kjc); SqliteKv& sqliteKv = getSqliteKv(js); + userSpan.setTag("db.system.name"_kjc, kj::str("cloudflare-durable-object-sql"_kjc)); + userSpan.setTag("db.operation.name"_kjc, kj::str("get"_kjc)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys"_kjc, kj::str(key)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys.count"_kjc, static_cast(1)); + kj::Maybe result; if (sqliteKv.get(key, [&](kj::ArrayPtr value) { result = deserializeV8Value(js, key, value); })) { @@ -28,6 +33,30 @@ jsg::Ref SyncKvStorage::list( auto userSpan = IoContext::current().makeUserTraceSpan("durable_object_storage_kv_list"_kjc); SqliteKv& sqliteKv = getSqliteKv(js); + userSpan.setTag("db.system.name"_kjc, kj::str("cloudflare-durable-object-sql"_kjc)); + userSpan.setTag("db.operation.name"_kjc, kj::str("list"_kjc)); + + KJ_IF_SOME(o, maybeOptions) { + KJ_IF_SOME(start, o.start) { + userSpan.setTag("cloudflare.durable_object.kv.query.start"_kjc, kj::str(start)); + } + KJ_IF_SOME(startAfter, o.startAfter) { + userSpan.setTag("cloudflare.durable_object.kv.query.startAfter"_kjc, kj::str(startAfter)); + } + KJ_IF_SOME(end, o.end) { + userSpan.setTag("cloudflare.durable_object.kv.query.end"_kjc, kj::str(end)); + } + KJ_IF_SOME(prefix, o.prefix) { + userSpan.setTag("cloudflare.durable_object.kv.query.prefix"_kjc, kj::str(prefix)); + } + KJ_IF_SOME(reverse, o.reverse) { + userSpan.setTag("cloudflare.durable_object.kv.query.reverse"_kjc, reverse); + } + KJ_IF_SOME(limit, o.limit) { + userSpan.setTag("cloudflare.durable_object.kv.query.limit"_kjc, static_cast(limit)); + } + } + // Convert our options to DurableObjectStorageOperations::ListOptions (which also have the // `allowConcurrency` and `noCache` options, which are irrelevant in the sync interface). auto asyncOptions = kj::mv(maybeOptions).map([&](ListOptions&& options) { @@ -71,6 +100,11 @@ void SyncKvStorage::put(jsg::Lock& js, kj::String key, jsg::JsValue value) { auto userSpan = IoContext::current().makeUserTraceSpan("durable_object_storage_kv_put"_kjc); SqliteKv& sqliteKv = getSqliteKv(js); + userSpan.setTag("db.system.name"_kjc, kj::str("cloudflare-durable-object-sql"_kjc)); + userSpan.setTag("db.operation.name"_kjc, kj::str("put"_kjc)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys"_kjc, kj::str(key)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys.count"_kjc, static_cast(1)); + sqliteKv.put(key, serializeV8Value(js, value)); } @@ -78,7 +112,17 @@ kj::OneOf SyncKvStorage::delete_(jsg::Lock& js, kj::String key) { auto userSpan = IoContext::current().makeUserTraceSpan("durable_object_storage_kv_delete"_kjc); SqliteKv& sqliteKv = getSqliteKv(js); - return sqliteKv.delete_(key); + userSpan.setTag("db.system.name"_kjc, kj::str("cloudflare-durable-object-sql"_kjc)); + userSpan.setTag("db.operation.name"_kjc, kj::str("delete"_kjc)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys"_kjc, kj::str(key)); + userSpan.setTag("cloudflare.durable_object.kv.query.keys.count"_kjc, static_cast(1)); + + auto deleted = sqliteKv.delete_(key); + + userSpan.setTag("cloudflare.durable_object.kv.response.deleted_count"_kjc, + static_cast(deleted ? 1 : 0)); + + return deleted; } } // namespace workerd::api