Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ updates:
# Re-enable once typescript-eslint ships TS >=7 support.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# mocha 12 is ESM-only and requires Node ^20.19 || >=22.12, but we still
# build and test on Node 18. Its lib/cli/options.cjs require()s an ESM
# module, which fails with ERR_REQUIRE_ESM on Node 18.
# Re-enable once we drop Node 18 support.
- dependency-name: "mocha"
update-types: ["version-update:semver-major"]
versioning-strategy: "increase"
labels:
- dependabot
Expand Down
31 changes: 28 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,34 @@ jobs:
run: |
content=`cat ./package.json | tr '\n' ' '`
echo "json=$content" >> $GITHUB_OUTPUT
- run: |
git tag v${{ fromJson(steps.pkg.outputs.json).version }}
git push https://x-access-token:${{ steps.octo-sts.outputs.token }}@github.com/${{ github.repository }}.git v${{ fromJson(steps.pkg.outputs.json).version }}
- name: Tag release
run: |
version="${{ fromJson(steps.pkg.outputs.json).version }}"
remote="https://x-access-token:${{ steps.octo-sts.outputs.token }}@github.com/${{ github.repository }}.git"
# Idempotent, so that a rerun after a partial failure can still
# repair the tag and the release notes.
if git ls-remote --tags "$remote" "v$version" | grep -q "v$version"; then
echo "Tag v$version already exists, skipping"
else
git tag "v$version"
git push "$remote" "v$version"
fi
- name: Release notes
env:
GH_TOKEN: ${{ steps.octo-sts.outputs.token }}
VERSION: ${{ fromJson(steps.pkg.outputs.json).version }}
run: |
# The release notes are the body of the proposal PR, looked up by its
# head branch. This resolves even after the branch is deleted on merge.
notes="$RUNNER_TEMP/v$VERSION.md"
gh pr view "v$VERSION-proposal" --json body --jq .body > "$notes"
# v5.x is currently the only release line. Once a second one ships,
# --latest has to be decided per branch rather than hardcoded.
if gh release view "v$VERSION" > /dev/null 2>&1; then
gh release edit "v$VERSION" --title "$VERSION" -F "$notes" --latest
else
gh release create "v$VERSION" --target "${{ github.ref_name }}" --title "$VERSION" -F "$notes" --latest
fi

publish_dev:
needs: build
Expand Down
108 changes: 108 additions & 0 deletions benchmark/name-caching.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use strict'

const { Script } = require('vm')
const { isMainThread } = require('worker_threads')
const { TimeProfiler } = require('../out/src/time-profiler-bindings')

const scriptCount = Number(process.env.SCRIPT_COUNT || '500')
const functionsPerScript = Number(process.env.FUNCTIONS_PER_SCRIPT || '8')
const rounds = Number(process.env.ROUNDS || '8')
const work = Number(process.env.WORK || '1000')
const lines = Number(process.env.LINES || '32')
const iterations = Number(process.env.ITERATIONS || '9')
const warmup = Number(process.env.WARMUP || '3')
const nonAsciiNames = process.env.NON_ASCII_NAMES === '1'

function compileScript (scriptIndex) {
const handlers = []
const calls = []

for (let i = 0; i < functionsPerScript; i++) {
const name = `${nonAsciiNames ? '处理器' : 'handler'}_${scriptIndex}_${i}`
const statements = Array.from({ length: lines }, (_, line) =>
`for (let k = 0; k < ${work}; k++) total += Math.sqrt(k * n + ${i + line})`
)
handlers.push(`
function ${name}(n) {
let total = 0
${statements.join('\n')}
return total
}`)
calls.push(`total += ${name}(n + ${i})`)
}

return new Script(`
(() => {
${handlers.join('\n')}
return function ${nonAsciiNames ? '运行' : 'run'}_${scriptIndex}(n) {
let total = 0
${calls.join('\n')}
return total
}
})()
`, { filename: `/opt/${nonAsciiNames ? '服务' : 'service'}/dist/modules/module-${scriptIndex}.js` })
.runInThisContext()
}

function countNodes (node) {
let count = 1
for (const child of node.children) count += countNodes(child)
return count
}

function median (values) {
return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]
}

const scripts = Array.from({ length: scriptCount }, (_, i) => compileScript(i))
const stopMicros = []
const nanosPerNode = []
const nodeCounts = []

for (let iteration = 0; iteration < warmup + iterations; iteration++) {
const profiler = new TimeProfiler({
intervalMicros: 50,
durationMillis: 60000,
lineNumbers: true,
withContexts: false,
workaroundV8Bug: false,
collectCpuTime: false,
collectAsyncId: false,
isMainThread,
useCPED: false
})
profiler.start()

let result = 0
for (let round = 0; round < rounds; round++) {
for (const run of scripts) result += run(round + 1)
}

const start = process.hrtime.bigint()
const profile = profiler.stop(false)
const elapsed = Number(process.hrtime.bigint() - start)
profiler.dispose()

if (!Number.isFinite(result)) throw new Error('benchmark workload failed')
if (iteration < warmup) continue

const nodes = countNodes(profile.topDownRoot)
stopMicros.push(elapsed / 1000)
nanosPerNode.push(elapsed / nodes)
nodeCounts.push(nodes)
}

console.log(JSON.stringify({
name: 'profile-name-caching',
scripts: scriptCount,
functionsPerScript,
lines,
nonAsciiNames,
rounds,
work,
iterations,
warmup,
medianNodes: median(nodeCounts),
medianStopMicros: median(stopMicros),
medianNanosPerNode: median(nanosPerNode)
}))
24 changes: 24 additions & 0 deletions binding.gyp
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@
},
}
],
["address_sanitizer != 'true' and thread_sanitizer != 'true'", {
'xcode_settings': {
'GCC_GENERATE_DEBUGGING_SYMBOLS': 'NO',
'GCC_SYMBOLS_PRIVATE_EXTERN': 'YES', # -fvisibility=hidden
'GCC_INLINES_ARE_PRIVATE_EXTERN': 'YES',
'DEAD_CODE_STRIPPING': 'YES', # -dead_strip
},
"conditions": [
["OS == 'linux'", {
"cflags": [
"-fvisibility=hidden",
"-ffunction-sections",
"-fdata-sections",
],
"cflags_cc": ["-fvisibility-inlines-hidden"],
"ldflags": [ "-Wl,--gc-sections" ],
}],
["OS == 'win'", {
'msvs_settings': {
'VCLinkerTool': { 'OptimizeReferences': 2 }, # /OPT:REF
},
}],
],
}],
["address_sanitizer == 'true' and OS == 'mac'", {
'xcode_settings': {
'OTHER_CFLAGS+': [
Expand Down
145 changes: 95 additions & 50 deletions bindings/otel-thread-ctx.cc
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@
#include <new>
#include <vector>

// Byte offset, within an object created from an API template such as
// ThreadContext's, of the pointer stored in internal field 0. Different
// in some Node.js versions.
#if NODE_MAJOR_VERSION >= 23
constexpr int kRecordSlotOffset =
v8::internal::Internals::kJSAPIObjectWithEmbedderSlotsHeaderSize +
v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset;
#elif NODE_MAJOR_VERSION >= 22
constexpr int kRecordSlotOffset =
v8::internal::Internals::kJSObjectHeaderSize +
v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset;
#else
// not used
constexpr int kRecordSlotOffset = 0;
#endif
static_assert(kRecordSlotOffset >= 0 && kRecordSlotOffset <= UINT8_MAX,
"record_slot_offset must fit its uint8 field");

// Single thread-local read from outside the process via TLSDESC. It
// identifies, for the current V8 isolate's thread:
//
Expand All @@ -60,13 +78,16 @@
// AsyncContextFrame map (`als_handle`),
// - that instance's JS identity hash (`als_identity_hash`), so the
// reader can restrict the lookup to a single hash bucket.
// - the byte offset of internal field 0 within the wrapper JSObject the
// frame maps our key to (`record_slot_offset`), which holds the record
// pointer.
// - the (per-isolate) tagged address of the `undefined` singleton
// (`undefined_addr`). After looking up the value for our ALS key in
// the ACF map, the reader can compare against this to skip the
// JSObject / internal-field-0 dereference when no ThreadContext is
// currently attached; without it, a reader walking through undefined
// would have to rely on structural validation of the bytes at
// undefined+js_object_record_offset to detect the absence.
// undefined+<record slot offset> to detect the absence.
//
// Layout is part of the reader ABI: see the README "Discovery contract"
// section and the static_asserts below.
Expand All @@ -75,9 +96,11 @@ using v8::Global;
using v8::Object;

struct otel_thread_ctx_nodejs_v1_t {
v8::internal::Address* cped_slot; // offset 0
Global<Object> als_handle; // offset sizeof(void*); 1 V8 ptr
int als_identity_hash; // offset 2 * sizeof(void*); 4 + 4 pad
v8::internal::Address* cped_slot; // offset 0
Global<Object> als_handle; // offset sizeof(void*); 1 V8 ptr
int als_identity_hash; // offset 2 * sizeof(void*)
uint8_t record_slot_offset = kRecordSlotOffset; // 2 * sizeof(void*) + 4
uint8_t reserved[3] = {}; // 2 * sizeof(void*) + 5
v8::internal::Address undefined_addr; // offset 3 * sizeof(void*); tagged
};

Expand All @@ -100,9 +123,12 @@ static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_handle) ==
static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_identity_hash) ==
2 * sizeof(void*),
"als_identity_hash must immediately follow als_handle");
static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, record_slot_offset) ==
2 * sizeof(void*) + 4,
"record_slot_offset must immediately follow als_identity_hash");
static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, undefined_addr) ==
3 * sizeof(void*),
"undefined_addr must follow als_identity_hash + padding");
"undefined_addr must follow record_slot_offset + reserved");

namespace dd {
namespace {
Expand Down Expand Up @@ -880,8 +906,13 @@ void StoreAls(const FunctionCallbackInfo<Value>& args) {
// Cache the per-isolate undefined singleton's tagged address. Undefined
// is a read-only-roots heap object, never moves, so a cached numeric
// address is fine — no Global<> tracking needed.
#if NODE_MAJOR_VERSION >= 22
otel_thread_ctx_nodejs_v1.undefined_addr =
reinterpret_cast<v8::internal::Address>(*v8::Undefined(isolate));
v8::internal::ValueHelper::ValueAsAddress(*v8::Undefined(isolate));
#else
// Unreachable from JS; nonzero for the cleanup-hook bookkeeping.
otel_thread_ctx_nodejs_v1.undefined_addr = 1;
#endif

// Write `cped_slot` last with signal fence + volatile. It is what a reader
// tests before it dereferences anything, so publishing it after every other
Expand All @@ -901,40 +932,60 @@ void GetStoredAlsHash(const FunctionCallbackInfo<Value>& args) {
Integer::New(isolate, otel_thread_ctx_nodejs_v1.als_identity_hash));
}

// V8 layout constants captured at addon-compile time from the same V8
// headers Node bundles. Published via the discovery contract so an
// out-of-process reader can decode V8's JSObject / internal hashmap
// layout without doing its own V8-internal-symbol lookups for the
// pointer-compression / sandbox state. Note that nothing published here
// describes our own wrapper: internal field 0 points straight at the
// record, so the reader needs no offset of ours to reach it.
// The nodejs_v1 discovery schema does not publish V8's object layout; it
// fixes it, presuming the V8 Node.js builds by default: 64-bit, pointer
// compression off, sandbox off. These assertions check that presumption
// against the V8 headers we are compiled with, so a build not matching
// the schema will fail to compile.
//
// Each value the reader needs equals one of V8's public constants:
// tagged size (8) kApiTaggedSize
// JSMap table offset (0x18) kJSObjectHeaderSize, because JSCollection
// adds a single `table` field to JSObject
// (deps/v8/src/objects/js-collection.h)
// OrderedHashMap header kFixedArrayHeaderSize, because
// size (0x10) OrderedHashTable derives from FixedArray
// (deps/v8/src/objects/ordered-hash-table.h)
static_assert(v8::internal::kApiTaggedSize == 8,
"nodejs_v1 assumes a V8 built without pointer compression");
static_assert(v8::internal::Internals::kJSObjectHeaderSize == 0x18,
"unexpected V8 JSObject header size");
static_assert(v8::internal::Internals::kFixedArrayHeaderSize == 0x10,
"unexpected V8 FixedArray header size");
#if NODE_MAJOR_VERSION >= 22
constexpr int JS_OBJECT_RECORD_OFFSET =
v8::internal::Internals::kJSObjectHeaderSize +
// Node < 22 lacks this constant; the contract is unusable there anyway,
// as it has no ContinuationPreservedEmbedderData either (see StoreAls).
constexpr int kEmbedderDataSlotExternalPtrOffset =
v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset;
static_assert(kEmbedderDataSlotExternalPtrOffset == 0,
"nodejs_v1 assumes a V8 built without the sandbox");
#endif

// Whether internal field 0 of an object created from an API template really
// sits at kRecordSlotOffset. Set the field on a probe object and read it back
// at the offset.
bool RecordSlotOffsetHolds(v8::Isolate* isolate,
v8::Local<v8::Context> context) {
#if NODE_MAJOR_VERSION >= 22
v8::HandleScope scope(isolate);
v8::Local<v8::ObjectTemplate> tpl = v8::ObjectTemplate::New(isolate);
tpl->SetInternalFieldCount(1);
v8::Local<v8::Object> probe;
if (!tpl->NewInstance(context).ToLocal(&probe)) return false;
// Any aligned address will do; this one is ours and can't collide.
static int marker;
SetAlignedPointerInInternalField(probe, 0, &marker);
const char* object = reinterpret_cast<const char*>(
v8::internal::ValueHelper::ValueAsAddress(*probe) -
v8::internal::kHeapObjectTag);
void* at_offset;
memcpy(&at_offset, object + kRecordSlotOffset, sizeof(at_offset));
return at_offset == &marker;
#else
// Node < 22 lacks kEmbedderDataSlotExternalPointerOffset. The discovery
// contract isn't usable on these versions (no ContinuationPreservedEmbedderData
// either — see StoreAls), so this value is published only to keep the
// addon's exported surface consistent across Node majors. A would-be
// reader cannot reach a live record through it.
constexpr int JS_OBJECT_RECORD_OFFSET = 0;
// No ContinuationPreservedEmbedderData, so nothing to publish anyway.
return false;
#endif
constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize;

// V8 JSMap layout: kTableOffset within the JSMap object holds a tagged
// pointer to the backing OrderedHashMap table. Not exposed in V8's
// public headers; kept in sync with
// deps/v8/src/objects/js-collection.h (JSCollection::kTableOffset)
// and the torque-generated JSCollection layout.
constexpr int JS_MAP_TABLE_OFFSET = 0x18;

// V8 OrderedHashMap layout: the on-heap table starts with a 16-byte
// header before the element_count / deleted_element_count /
// number_of_buckets fields. Not exposed in V8's public headers; kept in
// sync with deps/v8/src/objects/ordered-hash-table.h
// (OrderedHashTable base layout).
constexpr int ORDERED_HASH_MAP_HEADER_SIZE = 0x10;
}

} // namespace

Expand All @@ -943,20 +994,14 @@ void OtelThreadCtx::Init(Local<Object> exports) {
NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls);
NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash);

Isolate* isolate = Isolate::GetCurrent();
Local<Context> ctx = isolate->GetCurrentContext();
auto publish_int = [&](const char* name, int value) {
exports
->Set(ctx,
String::NewFromUtf8(isolate, name).ToLocalChecked(),
Integer::New(isolate, value))
.FromJust();
};
publish_int("otelThreadCtxJsMapTableOffset", JS_MAP_TABLE_OFFSET);
publish_int("otelThreadCtxOrderedHashMapHeaderSize",
ORDERED_HASH_MAP_HEADER_SIZE);
publish_int("otelThreadCtxTaggedSize", TAGGED_SIZE);
publish_int("otelThreadCtxJsObjectRecordOffset", JS_OBJECT_RECORD_OFFSET);
v8::Isolate* isolate = v8::Isolate::GetCurrent();
v8::Local<v8::Context> context = isolate->GetCurrentContext();
exports
->Set(context,
v8::String::NewFromUtf8Literal(
isolate, "otelThreadCtxRecordSlotOffsetHolds"),
v8::Boolean::New(isolate, RecordSlotOffsetHolds(isolate, context)))
.FromJust();
}

} // namespace dd
Loading
Loading