This guide covers running InsightClient inside AWS Lambda (or any similar
freeze-on-exit serverless runtime) using lambdaMode(true).
For the general forwarder/collector model see the InsightClient javadoc; for the
companion avaje-metrics OpenTelemetry Lambda recipe (the ScheduledTask /
TelemetryWaiter waitIfRunning() machinery) see the avaje-metrics
add-open-telemetry-lambda guide.
AWS Lambda freezes the worker the moment the handler returns and thaws it
for the next invocation. Two things in the default InsightClient are unsafe there:
- Background timers. The internal metric
Timerand theQueryPlanCapturebackground poll run on their own threads. Once the worker freezes those threads stop; a low-traffic Lambda may never let them tick. - Async HTTP callbacks. Metric/plan POSTs default to
HttpClient.sendAsync(...)and handle the response (which carries the server's plan-capture directives) on an HttpClient thread after the call site returns — so the response can be lost to a freeze before it is processed.
lambdaMode(true) removes both: no background threads, all I/O is
synchronous on the calling thread, and query-plan capture is advanced
inline from accept(ServerMetrics) each report cycle. Because the work runs on
the invocation thread, your handler's existing waitIfRunning() drain already
covers insight reporting — no extra waiter is needed.
register() (below) needs avaje-metrics-ebean on the classpath. It is an
optional dependency of ebean-insight, so pull it in alongside the client.
The simplest option is the avaje-metrics-ebean-insight aggregator, which brings
both together:
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-metrics-ebean-insight</artifactId>
<version>LATEST</version>
</dependency>Or add the two explicitly:
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-insight</artifactId>
<version>LATEST</version>
</dependency>
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-metrics-ebean</artifactId>
<version>LATEST</version>
</dependency>You already have Ebean and avaje-metrics in a Lambda that reports metrics.
Build the InsightClient once when the handler class is loaded (Lambda reuses
the same handler instance across invocations on a warm worker) and register it
as a forwarder against the same avaje-metrics registry your Lambda already reports
from.
public class ConsolidationHandler implements RequestHandler<SQSEvent, Void> {
// built once per warm worker, reused across invocations
private static final InsightClient INSIGHT =
InsightClient.builder()
.appName("consolidation")
.environment("prod")
.database(database)
.capturePlans(true)
.lambdaMode(true) // no background threads, synchronous I/O
.build()
.register(); // forward metrics + drive plan capture
...
}build() starts the client (no timers are scheduled in lambdaMode). register()
wires a DatabaseMetricSupplier per registered database that forwards every
reset-on-read snapshot to the client via accept(ServerMetrics).
collectEbeanMetrics / collectAvajeMetrics default to false — leave them so
the client never tries to poll on its own; collection is owned by the upstream
avaje-metrics registry poll.
In lambdaMode the client does nothing on its own — it must be driven by a registry
collection. Whatever already collects your avaje-metrics registry on each invocation
(an avaje-metrics ScheduledTask reporter, the avaje-metrics-otel periodic reader,
etc.) now also drives insight, because register() adds the forwarder to that same
registry:
registry collected → DatabaseMetricSupplier.collectMetrics() (reset-on-read poll)
→ forwardTo → InsightClient.accept(snapshot)
→ synchronous POST to insight-server
→ inline QueryPlanCapture.progress()
Every step runs on the thread that performed the collection. Keep that collection
inside the part of the invocation covered by waitIfRunning() so it completes
before the worker freezes:
@Timed(prefix = "lambda", span = Timed.SpanMode.ROOT)
public Void handleRequest(SQSEvent event, Context context) {
try {
consolidateService.consolidate(convert(event));
} finally {
// drains any in-flight registry report (which includes the synchronous
// insight POST + inline plan progress) before the worker freezes
scheduledTask.waitIfRunning(2, SECONDS);
telemetryWaiter.waitIfRunning(); // if also exporting to OpenTelemetry
}
return null;
}If you have no existing reporter, collect the registry yourself once per invocation (still inside the handler so it completes before freeze):
metricRegistry.collectMetrics(); // triggers the forwarder → insight POSTPlan capture is an inherently multi-step, time-delayed protocol and is therefore best-effort on Lambda:
- A metrics POST response arms a capture for a slow query's hash.
- The application must re-execute that query so Ebean buffers its execution plan — on the same warm worker.
- After
captureDelaySeconds(default 60) a lateraccept()cycle harvests the plan and POSTs it.
The pending state lives on the heap, which a warm worker preserves across freeze/thaw, so capture works when traffic keeps the same worker alive across the window. Sparse traffic or a scale-down can leave a capture incomplete — it simply re-arms next time the query is seen.
Tune the arm→harvest window for Lambda with captureDelaySeconds(...). A shorter
delay collects sooner (helpful when workers are short-lived) at the risk of
harvesting before the query has re-run:
InsightClient.builder()
.database(database)
.capturePlans(true)
.lambdaMode(true)
.captureDelaySeconds(15) // default 60
.build()
.register();Note: plan capture targets the first registered database only.
All builder options have matching properties (resolved via avaje-config):
| Property | Default | Notes |
|---|---|---|
ebean.insight.lambdaMode |
false |
Synchronous, no-background-thread mode. |
ebean.insight.queryPlan.captureDelaySecs |
60 |
Arm→harvest window for plan capture. |
ebean.insight.collectEbeanMetrics |
false |
Keep false on Lambda (forwarder role). |
ebean.insight.collectAvajeMetrics |
false |
Keep false on Lambda (forwarder role). |
ebean.insight.enabled |
true |
Master on/off. |
-
lambdaMode(true)set (orebean.insight.lambdaMode=true). -
InsightClientbuilt once at handler class-init, not per invocation. -
register()called so the forwarder is on the reporting registry. -
collectEbeanMetrics/collectAvajeMetricsleftfalse. - The registry is collected each invocation and drained by
waitIfRunning()in afinallyblock before the handler returns.