Skip to content

feat: stamp service.instance.id on spans and share it with the heartb… #63

feat: stamp service.instance.id on spans and share it with the heartb…

feat: stamp service.instance.id on spans and share it with the heartb… #63

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run build
- name: LICENSE file and README agree with package.json license field
run: |
if [ ! -f LICENSE ]; then
echo "::error::LICENSE file is missing from repo root"; exit 1
fi
SPDX=$(node -e "console.log(require('./package.json').license)")
# Map the SPDX id declared in package.json to the exact opening line
# the corresponding license text is expected to start with. This is
# a whitelist, not a substring match, precisely because a substring
# check would false-pass a mismatch (e.g. "Apache-2.0" is not a
# substring of "Apache License", so a naive check misses that swap
# entirely). Only MIT is mapped: canonical Apache-2.0 text opens
# with an indented "Apache License" line, so a first-line
# comparison needs its own normalisation and is left for whoever
# adds Apache-2.0 support to get right and test, rather than
# carrying an entry here that would fail on a correct license.
# Extend this map if the SDK's license ever changes.
case "$SPDX" in
MIT) EXPECTED="MIT License" ;;
*)
echo "::error::no known LICENSE text mapping for SPDX id '$SPDX'; add one to this step"
exit 1
;;
esac
ACTUAL=$(head -n1 LICENSE)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "::error::package.json license is '$SPDX' but LICENSE begins with '$ACTUAL' (expected '$EXPECTED')"
exit 1
fi
# README.md must state the same SPDX id under its own "## License"
# heading. Scope extraction to the text between that heading and the
# next "## " heading (or EOF) rather than grepping the whole file,
# so incidental mentions of a license name elsewhere in the prose
# cannot produce a false pass or a false fail. If the section is
# renamed or removed this intentionally finds nothing and fails
# loudly below, rather than silently skipping the check.
README_SECTION=$(awk '/^## License/{flag=1; next} /^## /{flag=0} flag' README.md)
if [ -z "$(echo "$README_SECTION" | tr -d '[:space:]')" ]; then
echo "::error::README.md has no non-empty '## License' section; add one stating the SPDX id ('$SPDX')"
exit 1
fi
README_LICENSE=$(echo "$README_SECTION" | grep -v '^[[:space:]]*$' | head -n1 | xargs)
if [ "$README_LICENSE" != "$SPDX" ]; then
echo "::error::package.json license is '$SPDX' but README.md's ## License section says '$README_LICENSE'"
exit 1
fi
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["18", "20", "22", "24"]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
packaging:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["18", "20", "22", "24"]
# The consumer checks below call the real init(), which heartbeats by
# default to the real production endpoint with no API key: every run of
# this job put a burst of unauthenticated pings in the platform's
# auth-failure metrics. Off for the whole job so a consumer check added
# later cannot reintroduce that, and the one step that needs a heartbeat
# opts back in explicitly (an explicit option beats the environment).
env:
RIUS_HEARTBEAT: "false"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run build
- run: npx publint
# attw analyzes the packed tarball, so its verdict is the same on every
# matrix leg — and the tool itself crashes under Node 18 ("Cannot read
# properties of undefined (reading 'filename')", exit 3).
- if: matrix.node-version != '18'
run: npx --yes @arethetypeswrong/cli --pack .
- name: ESM and CJS entrypoints both resolve
run: |
npm pack --pack-destination /tmp >/dev/null
mkdir -p /tmp/consumer && cd /tmp/consumer && npm init -y >/dev/null
npm i /tmp/glassflow-ai-rius-*.tgz @opentelemetry/api >/dev/null
node -e "const m=require('@glassflow-ai/rius'); if(!m.init) throw new Error('CJS require broken')"
node --input-type=module -e "import * as m from '@glassflow-ai/rius'; if(!m.init) throw new Error('ESM import broken')"
- name: Dynamic import helper survives in dist/
run: |
# tsup code-splits the ESM output into content-hashed chunk files, so
# this cannot grep a fixed filename. Assert the dynamic-import helper
# survives somewhere in dist/, and that the CJS build was not
# rewritten to a require() form of the same call.
if ! grep -rq "return import(s)" dist/*.js dist/*.cjs; then
echo "::error::dynamic import helper not found anywhere in dist/"; exit 1
fi
if grep -q "require(s)" dist/index.cjs; then
echo "::error::dynamic import was rewritten to require() in the CJS build"; exit 1
fi
- name: heartbeat resolves its own version in the shipped CJS build
run: |
# heartbeat.ts resolves SDK_VERSION via createRequire(import.meta.url).
# tsup's CJS output stubs import.meta as `{}` unless `shims: true` is
# set in tsup.config.ts, which makes import.meta.url undefined,
# createRequire throw, and sdk_version silently degrade to "0.0.0"
# forever. Nothing else in the repo needs the shim, so this guards
# against it being removed as dead config. Asserted against the real
# dist/index.cjs through the public init(), because that is what
# consumers execute; a rebuild with different flags would prove
# nothing about the shipped artifact.
node -e "
const { init } = require('./dist/index.cjs');
const pkgVersion = require('./package.json').version;
let captured;
const client = init({
endpoint: 'http://example.invalid',
// Opts back in against the job-level RIUS_HEARTBEAT=false: this
// check reads a heartbeat payload. The injected transport keeps
// it off the network.
heartbeat: true,
heartbeatTransport: async (payload) => { captured = payload; },
});
setTimeout(async () => {
await client.shutdown();
if (captured === undefined) {
console.error('no heartbeat payload was captured');
process.exit(1);
}
if (captured.sdk_version !== pkgVersion) {
console.error('sdk_version resolved to ' + JSON.stringify(captured.sdk_version) + ', expected ' + JSON.stringify(pkgVersion));
process.exit(1);
}
console.log('sdk_version in dist/index.cjs: ' + captured.sdk_version);
}, 100);
"
- name: anthropic and langchain trace a real call from a CJS consumer
env:
NODE_MAJOR: ${{ matrix.node-version }}
run: |
# The unit tests run under vitest, which resolves @langchain/core
# through its own module graph. @langchain/core ships separate CJS and
# ESM builds, so a copy the SDK patched under one of them would not be
# the copy a plain `require` returns. This asserts a span comes out the
# far end of a packed install, which is the only proof that survives
# that difference. Anthropic is asserted here too because its module
# hook watches CJS require, so this is where it is expected to fire.
#
# @langchain/core 1.x declares engines.node >=20, so the langchain half
# is asserted only from that leg up. Numeric comparison (-lt), not
# string: "8" >= "20" lexicographically but 8 < 20 numerically.
cd /tmp/consumer
WANT_LANGCHAIN=1
if [ "$NODE_MAJOR" -lt 20 ]; then
echo "skipping the langchain half on Node $NODE_MAJOR (@langchain/core requires >=20)"
WANT_LANGCHAIN=0
fi
export WANT_LANGCHAIN
npm i @arizeai/openinference-instrumentation-anthropic @anthropic-ai/sdk @opentelemetry/sdk-trace-base >/dev/null
if [ "$WANT_LANGCHAIN" = "1" ]; then
npm i @arizeai/openinference-instrumentation-langchain @langchain/core >/dev/null
fi
node -e "
const http = require('node:http');
const { init } = require('@glassflow-ai/rius');
const { InMemorySpanExporter } = require('@opentelemetry/sdk-trace-base');
// Required BEFORE init(), the way real CJS apps order their
// requires. The entry patches the cached CJS exports directly; a
// require that only happens after init() would get the ESM build
// patched instead (the documented residual gap).
const Anthropic = require('@anthropic-ai/sdk').default;
const exporter = new InMemorySpanExporter();
const client = init({ spanExporter: exporter });
const reply = { id: 'm', type: 'message', role: 'assistant', model: 'claude-test',
content: [{ type: 'text', text: '4' }], stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 1 } };
const wantLangchain = process.env.WANT_LANGCHAIN === '1';
client.ready.then(async (enabled) => {
const want = wantLangchain ? ['anthropic', 'langchain'] : ['anthropic'];
for (const name of want) {
if (!enabled.includes(name)) {
throw new Error(name + ' did not load under CJS: ' + JSON.stringify(enabled));
}
}
if (wantLangchain) {
const { RunnableLambda } = require('@langchain/core/runnables');
await RunnableLambda.from((x) => 'echo:' + x).withConfig({ runName: 'CiEcho' }).invoke('hi');
}
const server = http.createServer((request, response) => {
request.resume();
request.on('end', () => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify(reply));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const anthropic = new Anthropic({ apiKey: 'not-a-real-key', baseURL: 'http://127.0.0.1:' + server.address().port });
await anthropic.messages.create({ model: 'claude-test', max_tokens: 8, messages: [{ role: 'user', content: '2+2' }] });
server.close();
// Read before shutdown(): InMemorySpanExporter.shutdown() clears
// what it collected. flush() drains the batch processor
// deterministically instead of guessing at its schedule delay.
await client.flush();
const names = exporter.getFinishedSpans().map((span) => span.name);
const expectedNames = wantLangchain ? ['CiEcho', 'Anthropic Messages'] : ['Anthropic Messages'];
for (const expected of expectedNames) {
if (!names.includes(expected)) {
throw new Error('no ' + expected + ' span was exported: ' + JSON.stringify(names));
}
}
process.exit(0);
}).catch((e) => { console.error(e.message); process.exit(1); });
"
- name: anthropic traces a real call from a pure-ESM consumer
run: |
# The point of the dual-build patching: a consumer that only ever
# `import`s the provider gets spans. The require hook cannot see ESM
# importers, so before this feature the call below produced nothing —
# while ready still reported the integration enabled. Static imports
# on purpose: they all evaluate before init() runs, which is exactly
# the ordering a real ESM app has. Asserted from a packed install
# because vitest's module graph does not reproduce a real consumer's
# ESM/CJS split.
cd /tmp/consumer
cat > esm-check.mjs <<'EOF'
import http from "node:http";
import { init } from "@glassflow-ai/rius";
import Anthropic from "@anthropic-ai/sdk";
import { InMemorySpanExporter } from "@opentelemetry/sdk-trace-base";
const exporter = new InMemorySpanExporter();
const client = init({ spanExporter: exporter });
const enabled = await client.ready;
if (!enabled.includes("anthropic")) {
throw new Error("anthropic did not load under ESM: " + JSON.stringify(enabled));
}
const reply = { id: "m", type: "message", role: "assistant", model: "claude-test",
content: [{ type: "text", text: "4" }], stop_reason: "end_turn",
usage: { input_tokens: 5, output_tokens: 1 } };
const server = http.createServer((request, response) => {
request.resume();
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(reply));
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const anthropic = new Anthropic({ apiKey: "not-a-real-key", baseURL: "http://127.0.0.1:" + server.address().port });
await anthropic.messages.create({ model: "claude-test", max_tokens: 8, messages: [{ role: "user", content: "2+2" }] });
server.close();
// Read before shutdown(): InMemorySpanExporter.shutdown() clears
// what it collected. flush() drains the batch processor
// deterministically instead of guessing at its schedule delay.
await client.flush();
const names = exporter.getFinishedSpans().map((span) => span.name);
if (!names.includes("Anthropic Messages")) {
throw new Error("no Anthropic Messages span from the ESM consumer: " + JSON.stringify(names));
}
EOF
node esm-check.mjs
- name: mcp traces a real tool call from CJS and ESM consumers
run: |
# The MCP SDK dual-builds (import -> dist/esm, require -> dist/cjs),
# so the registry's dynamic import patches a DIFFERENT Client class
# from the one a CJS consumer requires. Before the cached-CJS
# patching, this exact leg reproduced ready:["mcp"] with zero spans.
# Asserted from a packed install for the same reason as the provider
# legs: vitest's module graph does not reproduce a real consumer's
# ESM/CJS split. The span-kind assertion is the functional proof;
# "reports enabled" alone has hidden three defects now.
cd /tmp/consumer
npm i @modelcontextprotocol/sdk >/dev/null
cat > mcp-body.js <<'EOF'
module.exports = async function run(init, InMemorySpanExporter, mcpSdk) {
const { Client, McpServer, InMemoryTransport } = mcpSdk;
const exporter = new InMemorySpanExporter();
const client = init({ spanExporter: exporter });
const enabled = await client.ready;
if (!enabled.includes("mcp")) {
throw new Error("mcp did not load: " + JSON.stringify(enabled));
}
const server = new McpServer({ name: "t", version: "1.0.0" });
server.tool("add", {}, async () => ({ content: [{ type: "text", text: "3" }] }));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const mcp = new Client({ name: "c", version: "1.0.0" });
await mcp.connect(clientTransport);
await mcp.callTool({ name: "add", arguments: {} });
await client.flush();
const spans = exporter.getFinishedSpans();
const tool = spans.find((s) => s.name === "execute_tool add");
if (!tool) {
throw new Error("no tool span exported: " + JSON.stringify(spans.map((s) => s.name)));
}
if (tool.attributes["openinference.span.kind"] !== "TOOL") {
throw new Error("span is not a TOOL span: " + JSON.stringify(tool.attributes));
}
};
EOF
cat > mcp-check.cjs <<'EOF'
const { init } = require("@glassflow-ai/rius");
const { InMemorySpanExporter } = require("@opentelemetry/sdk-trace-base");
// Required BEFORE init(), the way real CJS apps order their requires;
// this is the require the cached-CJS patching exists to cover.
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { InMemoryTransport } = require("@modelcontextprotocol/sdk/inMemory.js");
const run = require("./mcp-body.js");
run(init, InMemorySpanExporter, { Client, McpServer, InMemoryTransport })
.then(() => process.exit(0))
.catch((e) => { console.error(e.message); process.exit(1); });
EOF
node mcp-check.cjs
cat > mcp-check.mjs <<'EOF'
import { createRequire } from "node:module";
import { init } from "@glassflow-ai/rius";
import { InMemorySpanExporter } from "@opentelemetry/sdk-trace-base";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
const run = createRequire(import.meta.url)("./mcp-body.js");
await run(init, InMemorySpanExporter, { Client, McpServer, InMemoryTransport });
EOF
node mcp-check.mjs
- name: vercel-ai turns a mock generateText into OpenInference spans (CJS consumer)
env:
NODE_MAJOR: ${{ matrix.node-version }}
run: |
# @arizeai/openinference-vercel declares engines.node >=22; only
# install and assert it on legs at or above that. Numeric comparison
# (-lt), not string comparison: "8" >= "22" lexicographically but
# 8 < 22 numerically, and every value here is a plain integer string.
#
# The functional shape on ai v7: the OTel spans come from the
# @ai-sdk/otel telemetry integration (ai itself no longer depends on
# @opentelemetry/api), and our processor entry transforms them with
# OpenInference attributes. Asserting on openinference.span.kind
# proves the PROCESSOR ran; "reports enabled" alone proves nothing.
# The ai package is ESM-only, so the CJS consumer reaches it through
# dynamic import, the way a real CJS app would.
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "skipping vercel-ai assertion on Node $NODE_MAJOR (requires >=22)"
exit 0
fi
cd /tmp/consumer
npm i @arizeai/openinference-vercel ai @ai-sdk/otel >/dev/null
cat > vercel-check.cjs <<'EOF'
const { init } = require("@glassflow-ai/rius");
const { InMemorySpanExporter } = require("@opentelemetry/sdk-trace-base");
const exporter = new InMemorySpanExporter();
const client = init({ spanExporter: exporter });
client.ready.then(async (enabled) => {
if (!enabled.includes("vercel-ai")) {
throw new Error("vercel-ai did not load under CJS: " + JSON.stringify(enabled));
}
const { generateText } = await import("ai");
const { MockLanguageModelV3 } = await import("ai/test");
const { OpenTelemetry } = await import("@ai-sdk/otel");
const mock = new MockLanguageModelV3({
doGenerate: async () => ({
content: [{ type: "text", text: "4" }],
finishReason: "stop",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6 },
warnings: [],
}),
});
await generateText({
model: mock,
prompt: "2+2",
experimental_telemetry: { isEnabled: true, integrations: [new OpenTelemetry()] },
});
await client.flush();
const spans = exporter.getFinishedSpans();
const kinds = spans.map((s) => s.attributes["openinference.span.kind"]);
if (!kinds.includes("LLM")) {
throw new Error(
"no OpenInference LLM span; the processor did not transform: " +
JSON.stringify(spans.map((s) => [s.name, s.attributes["openinference.span.kind"]])),
);
}
process.exit(0);
}).catch((e) => { console.error(e.message); process.exit(1); });
EOF
node vercel-check.cjs
- name: '@opentelemetry/api is a peer and is not bundled'
run: |
node -e "
const p = require('./package.json');
if (!p.peerDependencies?.['@opentelemetry/api']) throw new Error('must be a peerDependency');
if (p.dependencies?.['@opentelemetry/api']) throw new Error('must not be a dependency');
"
if grep -rq "AsyncLocalStorageContextManager" dist/*.js dist/*.cjs; then
echo "::error::@opentelemetry/api appears bundled into dist"; exit 1
fi