@cieljs/instrument
v0.0.1
Published
轻量, 类型安全且可组合的 TypeScript 函数插桩工具, 为日志, 遥测, Tracing, DevTools 与运行时观测提供统一的拦截基础
Readme
@cieljs/instrument
轻量、类型安全且可组合的 TypeScript 函数插桩工具。
为日志、遥测、Tracing、DevTools 与运行时观测提供统一的拦截基础。
我们经常希望知道一个函数:
- 什么时候开始执行?
- 接收了什么参数?
- 返回了什么结果?
- 执行花了多长时间?
- 为什么失败?
- 属于哪个模块、插件或能力?
- 应该如何被 DevTools、Logger 或 Telemetry 系统观察?
最直接的办法,是把这些逻辑写进业务代码。
async function searchMemory(query: string) {
const startedAt = performance.now();
try {
logger.debug('memory.search:start', {
query,
});
const result = await memory.search(query);
logger.debug('memory.search:end', {
duration: performance.now() - startedAt,
});
return result;
} catch (error) {
logger.error('memory.search:error', {
error,
});
throw error;
}
}但随着观测能力增加,业务函数会逐渐承担越来越多与自身无关的职责。
@cieljs/instrument 希望提供另一种可能:
让函数保持函数本身,让观测留在函数之外。
它提供一个很小的 instrumentation primitive,通过 Instrument、Interceptor 和可派生的 Context,将横切逻辑组合到普通 TypeScript 函数之外。
Features
- 🪶 Lightweight — 很小的运行时抽象,不绑定任何日志、遥测或 Tracing 实现
- 🔒 Type-safe — 包装前后完整保留函数参数与返回值类型
- 🧩 Composable — 多个 Interceptor 可以按照声明顺序组合
- 🌲 Derived Context — 使用
.with()分层绑定 Instrument 上下文 - 🛡️ Owner Priority — 上层声明的身份信息不会被下层调用方覆盖
- 🎯 Selective Interception — Interceptor 可以自行判断是否需要处理目标
- ♻️ Reusable — 匹配过程只发生在 instrument 阶段,包装结果可被重复调用
- 🫧 Transparent — 保留原始
this、同步返回、Promise 和异常语义 - 🔌 Framework Agnostic — 不依赖框架,可用于 Node.js、Bun、浏览器或任意 TypeScript Runtime
Installation
vp add @cieljs/instrument也可以使用其他包管理器:
pnpm add @cieljs/instrumentbun add @cieljs/instrumentnpm install @cieljs/instrumentQuick Start
创建一个 Interceptor:
import { createInstrumenter, type Interceptor } from '@cieljs/instrument';
const loggerInterceptor: Interceptor = {
intercept(_target, context) {
if (!context) {
return;
}
return next =>
function (...args) {
console.log(`[${context.name}] start`);
try {
return Reflect.apply(next, this, args);
} finally {
console.log(`[${context.name}] end`);
}
};
},
};创建 Instrumenter:
const instrument = createInstrumenter(loggerInterceptor);然后包装任意函数:
const add = instrument(
(a: number, b: number) => {
return a + b;
},
{
name: 'math.add',
},
);
add(1, 2);输出:
[math.add] start
[math.add] end函数签名不会发生变化:
// (a: number, b: number) => number
add;Instrument Context
每一个被观察的操作都可以拥有自己的 Context。
interface InstrumentContext<TMetadata> {
readonly name: string;
readonly metadata?: Readonly<Partial<TMetadata>>;
}其中:
name
稳定的操作标识。
{
name: 'ciel.memory.search',
}建议使用稳定且具有层级关系的名称:
ciel.memory.search
ciel.memory.write
ciel.signal.emit
ciel.percept.record它描述的是:
这个操作是什么。
而不是某一次调用发生了什么。
metadata
与操作本身相关的静态信息。
{
name: 'ciel.memory.search',
metadata: {
pluginId: 'memory',
capability: 'memory',
},
}Metadata 类型可以通过 createInstrumenter 定义:
interface Metadata {
pluginId: string;
capability: string;
query: string;
}
const instrument = createInstrumenter<Metadata>(interceptor);Derived Instrument
很多情况下,一组函数属于同一个模块、插件或者 Capability。
你不需要为每一个函数重复声明相同的 Context。
可以使用 .with() 派生新的 Instrument:
const instrument = createInstrumenter<Metadata>(interceptor);
const memoryInstrument = instrument.with({
metadata: {
pluginId: 'memory',
},
});
const searchInstrument = memoryInstrument.with({
name: 'ciel.memory.search',
metadata: {
capability: 'memory',
},
});然后:
const searchMemory = searchInstrument(
async query => {
return memory.search(query);
},
{
metadata: {
query: 'hello',
},
},
);最终 Interceptor 得到的 Context:
{
name: 'ciel.memory.search',
metadata: {
pluginId: 'memory',
capability: 'memory',
query: 'hello',
},
}这样可以自然形成:
Instrumenter
│
└── Plugin Instrument
│
└── Operation Instrument
│
└── Call-site ContextContext Ownership
.with() 不只是简单地合并对象。
@cieljs/instrument 将越早声明的 Preset 视为越接近 Instrument Owner 的信息。
因此:
上层 Preset
↓
下层 Preset
↓
调用 Context优先级为:
上层 Preset > 下层 Preset > 调用 Context例如:
const pluginInstrument = instrument.with({
metadata: {
pluginId: 'trusted-plugin',
},
});
const operationInstrument = pluginInstrument.with({
name: 'ciel.memory.search',
});调用方即使提供:
operationInstrument(fn, {
name: 'forged.operation',
metadata: {
pluginId: 'forged-plugin',
},
});最终仍然得到:
{
name: 'ciel.memory.search',
metadata: {
pluginId: 'trusted-plugin',
},
}这意味着:
Owner 声明身份,调用方补充信息。
调用方无法覆盖已经由上层 Instrument 固定的字段。
Interceptor
Interceptor 决定:
这个函数是否需要被我观察,以及我要如何包装它。
interface Interceptor<TMetadata> {
intercept<T extends AnyFunction>(
target: T,
context?: InstrumentContext<TMetadata>,
): InterceptorWrapper<T> | undefined;
}如果不希望处理目标:
const interceptor: Interceptor = {
intercept(target) {
if (target.name !== 'observed') {
return undefined;
}
return next => {
// ...
};
},
};因此一个 Instrumenter 可以同时安装不同用途的 Interceptor:
const instrument = createInstrumenter(
telemetryInterceptor,
loggerInterceptor,
devtoolsInterceptor,
profilingInterceptor,
);每一个 Interceptor 都可以拥有自己的匹配逻辑。
Wrapper Composition
Interceptor 返回一个 Wrapper:
type InterceptorWrapper<T> = (next: T) => T;例如:
const first: Interceptor = {
intercept() {
return next =>
function (...args) {
console.log('first:before');
const result = Reflect.apply(next, this, args);
console.log('first:after');
return result;
};
},
};多个 Wrapper 按照 Interceptor 的声明顺序嵌套:
createInstrumenter(first, second, third);执行顺序:
first:before
second:before
third:before
target
third:after
second:after
first:after这与常见的 middleware / around advice 模型保持一致。
Preserve Function Semantics
Instrumentation 不应该改变目标函数本身的调用语义。
this
原始 receiver 会继续传递给目标函数:
const calculate = instrument(function (this: { base: number }, delta: number) {
return this.base + delta;
});
calculate.call(
{
base: 2,
},
3,
);结果仍然是:
5Promise
异步函数保持原始 Promise:
const load = instrument(async () => {
return 42;
});
await load();Error
异常不会被 Instrument 自动吞掉:
const execute = instrument(() => {
throw new Error('failed');
});
execute();除非某个 Interceptor 明确改变这一行为,否则异常仍会按照原函数语义向上传播。
Why Instrument?
@cieljs/instrument 并不是 Logger。
也不是 Tracer。
更不是完整的 Observability SDK。
它只负责一件事情:
在不侵入业务实现的情况下,为普通函数建立统一、类型安全、可组合的观测入口。
因此具体能力可以作为 Interceptor 构建在它之上:
┌── Logger
│
├── OpenTelemetry
│
Function ─ Instrument ── DevTools
│
├── Metrics
│
├── Profiler
│
└── Custom InterceptorInstrument 不关心最终数据被发送到哪里。
它只提供稳定的 instrumentation boundary。
Example: Telemetry
例如可以实现一个简单的 Telemetry Interceptor:
const telemetryInterceptor: Interceptor = {
intercept(_target, context) {
if (!context) {
return;
}
return next =>
function (...args) {
const startedAt = performance.now();
try {
const result = Reflect.apply(next, this, args);
return result;
} finally {
telemetry.record({
name: context.name,
metadata: context.metadata,
duration: performance.now() - startedAt,
});
}
};
},
};以后同样可以接入:
OpenTelemetry
Sentry
Pino
DevTools
Custom Trace Collector而业务函数无需知道这些系统的存在。
Design Philosophy
@cieljs/instrument 保持几个简单的设计原则。
Instrumentation should be external.
业务逻辑不应该为了可观测性而改变自己的职责。
Context should describe identity.
Instrument Context 描述操作是什么,而不是承担完整的运行时状态管理。
Owners should own identity.
上层 Instrument 固定的信息不能被不可信的下层调用覆盖。
Interceptors should be composable.
Logging、Tracing、Metrics 和 DevTools 不应该互相依赖。
Functions should stay functions.
不引入新的 Handler、Task、Command 或 Procedure 抽象。
你交给 Instrument 的是普通函数。
最终得到的依然是普通函数。
API
createInstrumenter()
const instrument = createInstrumenter(...interceptors);创建一个独立的 Instrumenter。
不同 Instrumenter 之间不会共享 Interceptor 或状态。
instrument()
const wrapped = instrument(target, context);为函数应用所有匹配的 Interceptor。
instrument.with()
const derived = instrument.with({
name: 'ciel.memory.search',
metadata: {
pluginId: 'memory',
},
});创建一个继承当前 Preset 的新 Instrument。
原 Instrument 不会被修改。
Interceptor
const interceptor: Interceptor = {
intercept(target, context) {
return next => next;
},
};定义一个函数拦截器。
返回 undefined 表示跳过目标。
License
MIT
