cyclopes_
sign in sign up

Docs / Servers

Java and Spring Boot

Zero dependencies, so it cannot collide with anything your application already uses. On Spring Boot it is one dependency and one property; everything else is inferred.

What you need first

A project's DSN. Create a project, then copy it from that project's settings page: it carries the host and the project id, so it is the only thing this SDK needs to be pointed at.

https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>

The key is write-only: it can send events and nothing else, so shipping it inside a mobile app is fine. Read it from your environment rather than committing it.

Install

Add this install as a Maven repository, then the starter.

<!-- add https://cyclopes.localhost.co.zw/maven2 as a repository -->
<dependency>
  <groupId>zw.co.localhost.cyclopes</groupId>
  <artifactId>cyclopes-spring-boot-starter</artifactId>
  <version>0.4.0</version>
</dependency>

Wire it up

# application.yml, and that is the whole integration
cyclopes:
  dsn: https://<key>@cyclopes.localhost.co.zw/ingest/<project-id>

What is inferred

InferredFromWhy it matters
environmentthe active Spring profileso staging and production do not merge
releaseBuildProperties, when the Boot build plugin wrote anyso an issue can name the build it started in
in-app packagesthe package of your @SpringBootApplication classsee in_app
sessiona request header, else the servlet session, else per requestso users do not share one journey

Each can be overridden; none has to be.

What you get without writing anything

  • Exceptions, both the ones that escape to the container and the ones an @ExceptionHandler or @ControllerAdvice answers. The second group is the larger one in a well-kept application and is invisible to a filter alone, so the starter also registers a HandlerExceptionResolver. An exception is reported once, never twice.
  • A scope per request, popped in a finally block. Servlet threads are pooled, and a scope left behind is the next request wearing the last user's identity.
  • A flush on shutdown, so the last seconds of telemetry do not die with the JVM. That window is exactly the one worth having when a pod is being killed for the reason you are trying to diagnose.

Journeys

Cyclopes.identify(Map.of("id", user.getId(), "plan", user.getPlan()));
Cyclopes.screen("Cart", Map.of("items", cart.size()));
Cyclopes.track("checkout_started", Map.of("value", cart.total()));
Cyclopes.warn("slow query", Map.of("ms", elapsed, "table", "orders"));

identify applies to everything after it, not just its own row. (project, user_id) is one of only two join keys Cyclopes has, and an SDK that stamped it on the identify row alone would leave the rest of the session anonymous.

screen records where the user is and every track after it says so. Use Cyclopes.currentScreen("PromoDialog") for an arrival that is not a page of its own. It lives on the Scope when a thread has one, and the starter pushes one per request.

Sessions, which is the part to get right

(project, session_id) relates activity, logs and errors. There is no trace id and no implicit correlation, so a server that reports one session for the whole JVM produces a single enormous session belonging to nobody.

The starter takes the first of these that exists:

  1. A request header (X-Cyclopes-Session, then X-Request-Id, then X-Correlation-Id). Propagate one between services and a journey spans them.
  2. The servlet session, when one already exists. For a server-rendered application that is what a session honestly means. It is never created here: asking for one would turn a stateless API stateful just to measure it.
  3. A fresh id per request, so the request is at least self-consistent.

Outside a web request, scope it yourself. A message consumer or a scheduled job acting for one customer needs this as much as a controller does:

Cyclopes.withSession(order.getId(), scope -> {
    scope.user(Map.of("id", order.getCustomerId()));
    processOrder(order);
});

Plain Java

Cyclopes.init(new CyclopesOptions()
        .setDsn(System.getenv("CYCLOPES_DSN"))
        .setEnvironment("production")
        .setRelease("worker@1.4.2")
        .addInAppPackage("com.example"));

try {
    risky();
} catch (Exception e) {
    Cyclopes.captureException(e);
}

Runtime.getRuntime().addShutdownHook(new Thread(Cyclopes::close));

An absent DSN disables the SDK and is not an error, which is how a local profile opts out. A malformed one throws at init.

in_app, and why the starter guesses it for you

in_app decides which frames the UI shows first and which ones grouping keys on, so it matters more than any other frame field. On the JVM there is no filesystem signal to derive it from: every frame arrives from a jar. A list of known framework prefixes works right up until somebody uses a framework nobody listed.

Your application's own package is the actual answer, and Spring Boot already knows it. Outside Spring, say so:

options.addInAppPackage("com.example");

An explicit list is an answer rather than a heuristic: if you said where your code lives, nothing else gets a vote.

Kotlin and coroutines

The Java SDK works from Kotlin as it is. What it cannot do is survive a coroutine, and that is what cyclopes-kotlin adds.

<dependency>
  <groupId>zw.co.localhost.cyclopes</groupId>
  <artifactId>cyclopes-kotlin</artifactId>
  <version>0.4.0</version>
</dependency>

The Java scope lives in a ThreadLocal, which is exactly right for a servlet container and exactly wrong for coroutines: the moment withContext(Dispatchers.IO) moves the work to another thread, the scope there is gone, the session falls back to the process-wide one, and every user in flight merges into it. Nothing fails. The numbers just quietly stop meaning anything.

withCyclopesSession(requestId, user = mapOf("id" to user.id)) {
    val cart = withContext(Dispatchers.IO) { loadCart() }   // still this session
    track("cart_viewed", mapOf("items" to cart.size))
}

CyclopesContext is a ThreadContextElement, the coroutine machinery's own answer to this: the scope follows the coroutine rather than the thread running it, and the thread is left as it was found. Also here: initCyclopes { } as a builder, top-level track/screen/identify, and reportingFailures { }, which reports and rethrows rather than swallowing.

Guarantees

  • Never throws into your application. Every public method wraps its body.
  • Never blocks it. Sends happen on one background thread behind a bounded queue; a full queue drops rather than waits. An application that has lost access to its monitoring should get slower by exactly zero.
  • Drops rather than retries on 400, 401, 403, 413 and 429. Only transport errors and 5xx are retried, once.
  • Flushes before rotating a session, because an envelope stamps its session id when it is built, not when each item was recorded.

Redaction: options.setBeforeSend(event -> ...), returning the event or null to drop it. It runs on the calling thread, and a throw from it drops the event rather than propagating.