Skip to content

Commit 3a93bdb

Browse files
authored
Feat/eth send user operation timing logs (#30)
* feat(timing): instrument eth_sendUserOperation handler and validator Adds a small `timed()` helper and wraps the 6 parallel async ops in addToMempoolIfValid plus the validator's inner simulation RPC calls (simulateValidation + simulateHandleOp for v0.6; simulateValidation for v0.7). Replaces the unstructured console.log with a Pino structured log at handler entry and adds a total-duration log on exit. This makes wall-clock latency attributable to the slowest parallel step and surfaces debug_traceCall / simulation RPC time, which is the typical bottleneck for first-deploy boosted userOps (factory + initCode trace). * refactor(timing): use performance.now() for monotonic duration measurement Date.now() is wall-clock and can jump backward on NTP corrections or manual clock changes, producing negative or wildly inflated durations. performance.now() is monotonic by spec and has sub-ms resolution. Round the float delta to whole ms for log readability — sub-ms precision isn't useful for the network/RPC timings being measured (10s-100s of ms). * fix(logging): emit single-line JSON when Betterstack token is unset initProductionLogger previously fell back to initDebugLogger (pino-pretty) when BETTER_STACK_TOKEN was missing. pino-pretty splits one structured log across multiple lines, and Render's syslog forwarder treats each line as a separate Betterstack record -- making the structured object impossible to filter or correlate downstream. Switch the no-token fallback to plain pino (single-line JSON to STDOUT) while keeping the level + customSerializer formatters. Local-dev users who want pretty output should keep using initDebugLogger directly. * using betterstack endpoint for logs
1 parent 802fae7 commit 3a93bdb

5 files changed

Lines changed: 157 additions & 55 deletions

File tree

src/rpc/methods/eth_sendUserOperation.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import type * as validation from "@alto/types"
1111
import {
1212
calcExecutionPvgComponent,
1313
calcL2PvgComponent,
14-
getAAError
14+
getAAError,
15+
timed
1516
} from "@alto/utils"
1617
import type { Hex } from "viem"
1718
import { getNonceKeyAndSequence, getUserOpHash } from "../../utils/userop"
@@ -97,7 +98,11 @@ export async function addToMempoolIfValid({
9798
}): Promise<{ userOpHash: Hex; result: "added" | "queued" }> {
9899
rpcHandler.ensureEntryPointIsSupported(entryPoint)
99100

100-
// Execute multiple async operations in parallel
101+
const logCtx = { sender: userOp.sender, apiVersion, boost }
102+
103+
// Execute multiple async operations in parallel. Each step is timed
104+
// individually so the slowest can be identified from logs (wall-clock
105+
// latency = slowest step, not the sum).
101106
const [
102107
userOpHash,
103108
{ queuedUserOps, validationResult },
@@ -106,20 +111,32 @@ export async function addToMempoolIfValid({
106111
[preMempoolSuccess, preMempoolError],
107112
[validEip7702Auth, validEip7702AuthError]
108113
] = await Promise.all([
109-
getUserOpHash({
110-
userOp,
111-
entryPointAddress: entryPoint,
112-
chainId: rpcHandler.config.chainId,
113-
publicClient: rpcHandler.config.publicClient
114-
}),
115-
getUserOpValidationResult(rpcHandler, userOp, entryPoint),
116-
rpcHandler.getNonceSeq(userOp, entryPoint),
117-
validatePvg(apiVersion, rpcHandler, userOp, entryPoint, boost),
118-
rpcHandler.preMempoolChecks(userOp, apiVersion, boost),
119-
rpcHandler.validateEip7702Auth({
120-
userOp,
121-
validateSender: true
122-
})
114+
timed(rpcHandler.logger, "getUserOpHash", logCtx, () =>
115+
getUserOpHash({
116+
userOp,
117+
entryPointAddress: entryPoint,
118+
chainId: rpcHandler.config.chainId,
119+
publicClient: rpcHandler.config.publicClient
120+
})
121+
),
122+
timed(rpcHandler.logger, "getUserOpValidationResult", logCtx, () =>
123+
getUserOpValidationResult(rpcHandler, userOp, entryPoint)
124+
),
125+
timed(rpcHandler.logger, "getNonceSeq", logCtx, () =>
126+
rpcHandler.getNonceSeq(userOp, entryPoint)
127+
),
128+
timed(rpcHandler.logger, "validatePvg", logCtx, () =>
129+
validatePvg(apiVersion, rpcHandler, userOp, entryPoint, boost)
130+
),
131+
timed(rpcHandler.logger, "preMempoolChecks", logCtx, () =>
132+
rpcHandler.preMempoolChecks(userOp, apiVersion, boost)
133+
),
134+
timed(rpcHandler.logger, "validateEip7702Auth", logCtx, () =>
135+
rpcHandler.validateEip7702Auth({
136+
userOp,
137+
validateSender: true
138+
})
139+
)
123140
])
124141

125142
// Validate eip7702Auth
@@ -222,18 +239,20 @@ export const ethSendUserOperationHandler = createMethodHandler({
222239
method: "eth_sendUserOperation",
223240
schema: sendUserOperationSchema,
224241
handler: async ({ rpcHandler, params, apiVersion }) => {
225-
console.log("=== eth_sendUserOperation called ===")
242+
const handlerStart = performance.now()
226243
const [userOp, entryPoint] = params
227244

245+
const boost =
246+
userOp.maxFeePerGas === 0n && userOp.maxPriorityFeePerGas === 0n
247+
248+
rpcHandler.logger.info(
249+
{ sender: userOp.sender, apiVersion, boost, entryPoint },
250+
"[timing] eth_sendUserOperation received"
251+
)
252+
228253
let status: "added" | "queued" | "rejected" = "rejected"
254+
let resolvedUserOpHash: Hex | undefined
229255
try {
230-
let boost = false
231-
if (
232-
userOp.maxFeePerGas === 0n &&
233-
userOp.maxPriorityFeePerGas === 0n
234-
) {
235-
boost = true
236-
}
237256
const { result, userOpHash } = await addToMempoolIfValid({
238257
rpcHandler,
239258
userOp,
@@ -243,6 +262,7 @@ export const ethSendUserOperationHandler = createMethodHandler({
243262
})
244263

245264
status = result
265+
resolvedUserOpHash = userOpHash
246266

247267
rpcHandler.eventManager.emitReceived(userOpHash)
248268

@@ -257,6 +277,18 @@ export const ethSendUserOperationHandler = createMethodHandler({
257277
type: userOp.eip7702Auth ? "7702" : "regular"
258278
})
259279
.inc()
280+
281+
rpcHandler.logger.info(
282+
{
283+
sender: userOp.sender,
284+
apiVersion,
285+
boost,
286+
status,
287+
userOpHash: resolvedUserOpHash,
288+
ms: Math.round(performance.now() - handlerStart)
289+
},
290+
"[timing] eth_sendUserOperation total"
291+
)
260292
}
261293
}
262294
})

src/rpc/validation/UnsafeValidator.ts

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
entryPointExecutionErrorSchema07
2323
} from "@alto/types"
2424
import type { Logger, Metrics } from "@alto/utils"
25-
import { isVersion06 } from "@alto/utils"
25+
import { isVersion06, timed } from "@alto/utils"
2626
import * as sentry from "@sentry/node"
2727
import type { StateOverride } from "viem"
2828
import {
@@ -311,26 +311,41 @@ export class UnsafeValidator implements InterfaceValidator {
311311
eip7702Override = getEip7702DelegationOverrides([userOp])
312312
}
313313

314-
const simulateValidationPromise = entryPointContract.simulate
315-
.simulateValidation(
316-
[userOp],
317-
eip7702Override ? { stateOverride: eip7702Override } : {}
318-
)
319-
.catch((e) => {
320-
if (e instanceof Error) {
321-
return e
322-
}
323-
throw e
324-
})
314+
const v06LogCtx = { sender: userOp.sender, entryPoint, version: "0.6" }
315+
316+
const simulateValidationPromise = timed(
317+
this.logger,
318+
"v06.simulateValidation",
319+
v06LogCtx,
320+
() =>
321+
entryPointContract.simulate
322+
.simulateValidation(
323+
[userOp],
324+
eip7702Override
325+
? { stateOverride: eip7702Override }
326+
: {}
327+
)
328+
.catch((e) => {
329+
if (e instanceof Error) {
330+
return e
331+
}
332+
throw e
333+
})
334+
)
325335

326-
const runtimeValidationPromise =
327-
this.gasEstimationHandler.gasEstimator06.simulateHandleOp06({
328-
entryPoint,
329-
userOp,
330-
useCodeOverride: false, // disable code override so that call phase reverts aren't caught
331-
targetAddress: zeroAddress,
332-
targetCallData: "0x"
333-
})
336+
const runtimeValidationPromise = timed(
337+
this.logger,
338+
"v06.simulateHandleOp",
339+
v06LogCtx,
340+
() =>
341+
this.gasEstimationHandler.gasEstimator06.simulateHandleOp06({
342+
entryPoint,
343+
userOp,
344+
useCodeOverride: false, // disable code override so that call phase reverts aren't caught
345+
targetAddress: zeroAddress,
346+
targetCallData: "0x"
347+
})
348+
)
334349

335350
const [simulateValidationResult, runtimeValidation] = await Promise.all(
336351
[simulateValidationPromise, runtimeValidationPromise]
@@ -475,12 +490,17 @@ export class UnsafeValidator implements InterfaceValidator {
475490
> {
476491
const { userOp, queuedUserOps, entryPoint } = args
477492

478-
const simulateValidationResult =
479-
await this.gasEstimationHandler.gasEstimator07.simulateValidation({
480-
entryPoint,
481-
userOp,
482-
queuedUserOps
483-
})
493+
const simulateValidationResult = await timed(
494+
this.logger,
495+
"v07.simulateValidation",
496+
{ sender: userOp.sender, entryPoint, version: "0.7" },
497+
() =>
498+
this.gasEstimationHandler.gasEstimator07.simulateValidation({
499+
entryPoint,
500+
userOp,
501+
queuedUserOps
502+
})
503+
)
484504

485505
if (simulateValidationResult.result === "failed") {
486506
throw new RpcError(

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export * from "./preVerificationGasCalulator"
55
export * from "./userop"
66
export * from "./helpers"
77
export * from "./asyncTimeout"
8+
export * from "./timed"
89
export type { Logger } from "pino"

src/utils/logger.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,17 @@ export const initDebugLogger = (level = "debug"): Logger => {
9090
let transport: any
9191

9292
if (process.env.BETTER_STACK_TOKEN) {
93+
const logtailOptions: { sourceToken: string; endpoint?: string } = {
94+
sourceToken: process.env.BETTER_STACK_TOKEN
95+
}
96+
if (process.env.BETTER_STACK_ENDPOINT) {
97+
logtailOptions.endpoint = process.env.BETTER_STACK_ENDPOINT
98+
}
99+
93100
// @ts-ignore - pino.transport exists at runtime but types may be incomplete
94101
transport = pino.transport({
95102
target: "@logtail/pino",
96-
options: { sourceToken: process.env.BETTER_STACK_TOKEN }
103+
options: logtailOptions
97104
})
98105

99106
let reviving = false
@@ -110,9 +117,7 @@ if (process.env.BETTER_STACK_TOKEN) {
110117
// @ts-ignore
111118
const revived = pino.transport({
112119
target: "@logtail/pino",
113-
options: {
114-
sourceToken: process.env.BETTER_STACK_TOKEN
115-
}
120+
options: logtailOptions
116121
})
117122
revived.on("error", handleTransportError)
118123
transport.write = revived.write.bind(revived)
@@ -130,7 +135,18 @@ if (process.env.BETTER_STACK_TOKEN) {
130135

131136
export const initProductionLogger = (level: string): Logger => {
132137
if (!transport) {
133-
return initDebugLogger(level)
138+
// No Betterstack token: emit single-line JSON to STDOUT so external
139+
// log shippers (syslog, Render's collector) treat each log as one
140+
// record. pino-pretty here splits structured objects across lines and
141+
// makes them unsearchable downstream.
142+
const l = pino({
143+
formatters: {
144+
level: logLevel,
145+
log: customSerializer
146+
}
147+
})
148+
l.level = level
149+
return l
134150
}
135151
const l = pino(transport)
136152
l.level = level

src/utils/timed.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Logger } from "pino"
2+
3+
// Times an async operation and emits a single structured log line on
4+
// completion. Used to attribute eth_sendUserOperation latency to specific
5+
// validator/RPC steps that otherwise run inside Promise.all and aren't
6+
// distinguishable from the outside.
7+
export async function timed<T>(
8+
logger: Logger,
9+
step: string,
10+
context: Record<string, unknown>,
11+
fn: () => T | Promise<T>
12+
): Promise<T> {
13+
const start = performance.now()
14+
try {
15+
const result = await fn()
16+
logger.info(
17+
{ ...context, step, ms: Math.round(performance.now() - start) },
18+
`[timing] ${step}`
19+
)
20+
return result
21+
} catch (err) {
22+
logger.warn(
23+
{
24+
...context,
25+
step,
26+
ms: Math.round(performance.now() - start),
27+
err: err instanceof Error ? err.message : String(err)
28+
},
29+
`[timing] ${step} failed`
30+
)
31+
throw err
32+
}
33+
}

0 commit comments

Comments
 (0)