@miradorlabs/plugins
v1.0.1
Published
Mirador SDK plugins for blockchain transaction and Safe multisig tracing
Readme
@miradorlabs/plugins
Shared plugin system for the Mirador SDKs (@miradorlabs/web-sdk and @miradorlabs/nodejs-sdk). Plugins extend traces with additional methods and contribute data during flush — without any proto dependencies.
Architecture
plugins/ # This package (proto-free)
├── src/
│ ├── plugin.ts # Core interfaces: MiradorPlugin, TraceContext, FlushBuilder
│ ├── hints.ts # HintType constants + HintDataMap type registry
│ ├── types.ts # Shared types: Chain, ChainInput, TxHashHint, Logger, etc.
│ ├── chains.ts # toChain(), resolveChainInput() utilities
│ ├── web3-plugin.ts # Web3Plugin — tx hints, sendTransaction, provider mgmt, Safe hints
│ └── index.ts # Public exports
web-sdk/src/ingest/
├── hint-serializers.ts # Proto serializers (protobuf.js class API)
├── trace.ts # createFlushBuilder() dispatches via HINT_SERIALIZERS
└── ...
nodejs-sdk/src/ingest/
├── hint-serializers.ts # Proto serializers (ts-proto interface API)
├── trace.ts # createFlushBuilder() dispatches via HINT_SERIALIZERS
└── ...Key design: Plugins call builder.addHint(type, data) with plain JS objects. Each SDK's hint-serializers.ts maps hint types to proto-specific serialization. This keeps plugins proto-free while both SDKs serialize correctly.
Using Existing Plugins
Web3Plugin
Adds blockchain transaction tracing methods under trace.web3.evm and Gnosis Safe multisig tracking under trace.web3.safe.
import { Client, Web3Plugin } from '@miradorlabs/web-sdk';
// or: import { Client, Web3Plugin } from '@miradorlabs/nodejs-sdk';
const client = new Client('your-api-key', {
plugins: [Web3Plugin({ provider: window.ethereum })],
});
const trace = client.trace({ name: 'swap' });
// EVM methods (under web3.evm namespace):
trace.web3.evm.addTxHint('0x123...', 'ethereum'); // Record a tx hash
trace.web3.evm.addTxHint('0x456...', 'polygon', { input: '0x...' }); // With calldata
trace.web3.evm.addTx({ hash: '0x...', chainId: 1 }); // From a tx object
trace.web3.evm.addInputData('0xabcdef...'); // Raw calldata
trace.web3.evm.resolveChain('ethereum'); // Resolve chain name
// Provider management:
trace.web3.evm.setProvider(newProvider); // Switch provider
trace.web3.evm.getProviderChain(); // Get detected chain
// Send a transaction (auto-captures tx hash + chain):
const txHash = await trace.web3.evm.sendTransaction(txParams);
// Or with an explicit provider:
const txHash2 = await trace.web3.evm.sendTransaction(txParams, otherProvider);
// Safe methods (under web3.safe namespace):
trace.web3.safe.addMsgHint('0xmsg...', 'ethereum', 'Approval message');
trace.web3.safe.addTxHint('0xsafetx...', 'ethereum', 'Execution tx');Method Chaining
All void-returning plugin methods support chaining. Chained calls return the root Trace, so you can mix namespaces and core methods freely:
trace
.web3.evm.addTxHint('0x123...', 'ethereum')
.web3.safe.addMsgHint('0xabc...', 'ethereum')
.addAttribute('user', '0xdef...')
.addTag('swap');Creating a New Plugin
1. Define Your Plugin
A plugin implements MiradorPlugin<TMethods>:
import type {
MiradorPlugin,
PluginSetupResult,
TraceContext,
FlushBuilder,
} from '@miradorlabs/plugins';
// Define the methods your plugin adds to Trace
export interface MyMethods {
trackAction(name: string, data: Record<string, unknown>): void;
getActionCount(): number;
}
export function MyPlugin(): MiradorPlugin<MyMethods> {
return {
name: 'my-plugin', // Unique name
setup(ctx: TraceContext): PluginSetupResult<MyMethods> {
// Plugin-local state (closure-scoped, per-trace)
const pendingActions: Array<{ name: string; data: Record<string, unknown>; timestamp: Date }> = [];
// Method implementations
function trackAction(name: string, data: Record<string, unknown>): void {
if (ctx.isClosed()) {
ctx.logger.warn('[MyPlugin] Trace is closed, ignoring trackAction');
return;
}
pendingActions.push({ name, data, timestamp: new Date() });
ctx.addEvent(`action:${name}`, data); // Use core trace primitives
ctx.addAttribute('last_action', name);
ctx.scheduleFlush(); // Trigger batched flush
}
function getActionCount(): number {
return pendingActions.length;
}
// Lifecycle hooks
function onFlush(builder: FlushBuilder): void {
// Contribute data to the flush payload
for (const action of pendingActions) {
builder.addEvent({
name: `plugin:${action.name}`,
details: JSON.stringify(action.data),
timestamp: action.timestamp,
});
}
pendingActions.length = 0; // Clear after flush
}
function hasPendingData(): boolean {
return pendingActions.length > 0;
}
function onClose(): void {
pendingActions.length = 0; // Cleanup
}
return {
methods: { trackAction, getActionCount },
noopMethods: { getActionCount: () => 0 }, // For sampled-out traces
onFlush,
onClose,
hasPendingData,
};
},
};
}2. Custom Namespaces
Plugins can nest their methods under namespaces by using nested objects in the TMethods type. The type system and runtime both handle arbitrary nesting automatically.
// Define methods under a namespace
export interface AnalyticsMethods {
analytics: {
track(event: string, data?: Record<string, unknown>): void;
identify(userId: string): void;
getSessionId(): string;
};
}
export function AnalyticsPlugin(): MiradorPlugin<AnalyticsMethods> {
return {
name: 'analytics',
setup(ctx: TraceContext): PluginSetupResult<AnalyticsMethods> {
const sessionId = crypto.randomUUID();
return {
methods: {
analytics: {
track(event, data) {
ctx.addEvent(`analytics:${event}`, data);
ctx.scheduleFlush();
},
identify(userId) {
ctx.addAttribute('analytics.userId', userId);
},
getSessionId() {
return sessionId;
},
},
},
noopMethods: {
analytics: { getSessionId: () => '' },
},
};
},
};
}Usage:
const client = new Client('key', {
plugins: [Web3Plugin(), AnalyticsPlugin()],
});
const trace = client.trace({ name: 'swap' });
// Namespaced access
trace.analytics.track('page_view', { page: '/swap' });
trace.analytics.identify('user123');
trace.analytics.getSessionId(); // Returns the session ID
// Chaining across namespaces — void methods return the root Trace
trace.analytics.track('click')
.web3.evm.addTxHint('0x...', 'ethereum')
.analytics.identify('user123')
.addAttribute('key', 'value');Multiple plugins can share a top-level namespace. TypeScript's intersection merges them automatically:
// Plugin A: { myNs: { foo(): void } }
// Plugin B: { myNs: { bar(): void } }
// Result: trace.myNs.foo() and trace.myNs.bar() both workYou can also nest arbitrarily deep: { a: { b: { c: { doThing(): void } } } } works.
3. Use Your Plugin
const client = new Client('key', {
plugins: [Web3Plugin(), MyPlugin()],
});
const trace = client.trace({ name: 'test' });
trace.trackAction('click', { button: 'submit' }); // Your method
trace.web3.evm.addTxHint('0x...', 'ethereum'); // Web3Plugin still works
trace.getActionCount(); // Returns 14. Plugin Lifecycle
client.trace({ name: 'test' })
│
├── plugin.setup(ctx) called for each plugin
│ └── Returns { methods, onFlush, onClose, hasPendingData }
│ └── methods are recursively merged onto the Trace instance (supports nested namespaces)
│
├── trace.trackAction(...) ← Your plugin method
│ └── Buffers data, calls ctx.scheduleFlush()
│
├── [microtask] flush triggered
│ ├── SDK builds FlushTraceData (events, attributes, tags)
│ ├── SDK creates FlushBuilder wrapping FlushTraceData
│ └── plugin.onFlush(builder) called for each plugin
│ └── Plugin dumps buffered data via builder
│
└── trace.close()
└── plugin.onClose() called for each plugin5. TraceContext API
The ctx object provides these methods for plugins:
| Method | Description |
|--------|-------------|
| ctx.addEvent(name, details?, options?) | Record an event (options: captureStackTrace, severity) |
| ctx.addAttribute(key, value) | Set a trace attribute |
| ctx.addAttributes(attrs) | Set multiple attributes |
| ctx.addTag(tag) | Add a tag |
| ctx.addTags(tags) | Add multiple tags |
| ctx.getTraceId() | Get the trace ID |
| ctx.isClosed() | Check if trace is closed |
| ctx.scheduleFlush() | Trigger a batched flush |
| ctx.logger | Logger instance (debug, warn, error) |
6. FlushBuilder API
The builder object in onFlush provides:
| Method | Description |
|--------|-------------|
| builder.addHint(type, data) | Add a typed hint (see hint types below) |
| builder.addEvent(event) | Add an event ({ name, details?, timestamp, severity? }) |
| builder.addAttribute(key, value) | Add an attribute |
| builder.addTag(tag) | Add a tag |
Adding a Custom Hint Type
If your plugin needs to contribute structured data beyond events/attributes (e.g., a new proto field on the backend), you need to register a hint type.
1. Add the hint type constant
In plugins/src/hints.ts:
export const HintType = {
TX_HASH: 'tx_hash',
SAFE_MSG: 'safe_msg',
SAFE_TX: 'safe_tx',
MY_HINT: 'my_hint', // Add your type
} as const;2. Add the data shape to HintDataMap
In plugins/src/hints.ts:
export interface HintDataMap {
// ... existing entries
[HintType.MY_HINT]: {
field1: string;
field2: number;
chain: Chain;
timestamp: Date;
};
}3. Add serializers in each SDK
In web-sdk/src/ingest/hint-serializers.ts and nodejs-sdk/src/ingest/hint-serializers.ts, add an entry to HINT_SERIALIZERS that maps your hint type to proto serialization.
4. Use in your plugin's onFlush
function onFlush(builder: FlushBuilder): void {
for (const item of pendingItems) {
builder.addHint(HintType.MY_HINT, item); // Type-safe!
}
pendingItems.length = 0;
}Best Practices
- Check
ctx.isClosed()before buffering data in plugin methods - Call
ctx.scheduleFlush()after adding data — this batches flushes via microtask - Clear buffers in
onFlush— set.length = 0after iterating - Clear buffers in
onClose— prevent memory leaks - Implement
hasPendingData()— the SDK uses this to decide whether to flush - Provide
noopMethodsfor methods that return values — these are used for sampled-out traces - Use unique plugin names — duplicate names will log a warning
Building
npm run build # Outputs to dist/ (ESM + CJS + type declarations)Both SDKs depend on this package via "@miradorlabs/plugins": "file:../plugins" and inline it into their bundles via Rollup's node-resolve plugin. Consumers of the SDKs don't need to install this package separately.
