Verify Android-Kotlin SDK events
An event moves through four stages between your track() call and Amplitude's servers. Verify each stage in order. The first stage where the event looks wrong is where the problem is.
| Stage | Question | Where to look |
|---|---|---|
| Captured | Did the SDK accept the call? | Debug logs in Logcat |
| Transformed | What did plugins add, change, or drop? | An inspection plugin |
| Sent | Did the request leave the device, and with what payload? | Debug logs, a proxy, or a custom HTTP client |
| Received | Did Amplitude accept and store the event? | The event callback, then User Lookup in Amplitude |
For problems common to every Amplitude SDK, go to SDK troubleshooting and debugging. If behavior changed after an SDK upgrade, check the Android-Kotlin SDK release notes.
Turn on debug logging
The SDK logs to Logcat with the tag Amplitude. The default level is INFO. Set it to DEBUG while you verify:
import com.amplitude.common.Logger
amplitude.logger.logMode = Logger.LogMode.DEBUG
The levels are DEBUG, INFO, WARN, ERROR, and OFF. In Android Studio, open Logcat and filter on tag:Amplitude. Return the level to INFO or higher before you ship.
Send logs to your own logger
To route SDK logs somewhere other than Logcat, such as your crash reporter, implement LoggerProvider and set loggerProvider:
import com.amplitude.common.Logger
import com.amplitude.core.LoggerProvider
class MyLogger : Logger {
override var logMode: Logger.LogMode = Logger.LogMode.WARN
override fun debug(message: String) { /* ... */ }
override fun info(message: String) { /* ... */ }
override fun warn(message: String) { /* ... */ }
override fun error(message: String) { /* ... */ }
}
val amplitude = Amplitude("YOUR_API_KEY", applicationContext) {
loggerProvider = object : LoggerProvider {
override fun getLogger(amplitude: com.amplitude.core.Amplitude): Logger = MyLogger()
}
}
Check what the SDK captured
With debug logging on, the SDK logs one line for each event it accepts:
D/Amplitude: Logged event with type: Sign Up Clicked
If that line doesn't appear, look for one of these instead:
| Logcat message | Meaning |
|---|---|
Skip event for opt out config. | Opt-out is on. The SDK drops every event. |
Event has an empty event type, dropping it. | The event name is empty. |
Dropping event '<type>': this Amplitude instance was replaced. | Your code created a newer instance with the same instanceName. Call track() on the newer instance. |
Invalid revenue object, missing required fields | You passed a Revenue without price. |
Attempting to perform operation ... ignoring (in standard output, not Logcat) | An Identify operation had an empty property name or a null value, or repeated a property. |
At this stage you can also read the state the SDK attaches to every event:
amplitude.isBuilt.await() // inside a coroutine
amplitude.getDeviceId() // "3f6c1e2a-...R"
amplitude.getUserId() // "user-12345" or null
amplitude.sessionId // 1761480000000, or -1 before the first session
amplitude.optOut // false
Check how plugins transformed the event
After capture, each event passes through the plugin pipeline in a fixed order:
- Before plugins (
Plugin.Type.Before): The built-in context plugin addsdevice_id,user_id,session_id,time,insert_id,library,platform, device and OS fields, andip, subject totrackingOptions. - Enrichment plugins (
Plugin.Type.Enrichment): Your own enrichment plugins run here, in the order you add them. - Destination plugins (
Plugin.Type.Destination): The built-in Amplitude destination writes the event to the upload queue.
A before or enrichment plugin that returns null drops the event. To see the exact event the SDK sends, add a destination plugin that records each event:
import com.amplitude.core.events.BaseEvent
import com.amplitude.core.platform.DestinationPlugin
class InspectEvents : DestinationPlugin() {
override fun track(payload: BaseEvent): BaseEvent {
amplitude.logger.debug("Final event: ${payload.eventType} ${payload.eventProperties} device=${payload.deviceId} session=${payload.sessionId}")
return payload
}
}
val inspector = InspectEvents()
amplitude.add(inspector)
Destination plugins receive the event after every before and enrichment plugin has run. Identify events arrive in identify(), and revenue events in revenue(). Remove the plugin with amplitude.remove(inspector) when you're done.
Check what the SDK sent
The SDK uploads events with a POST request to the endpoint for your configuration:
| Configuration | Request URL |
|---|---|
| Default (US) | https://api2.amplitude.com/2/httpapi |
ServerZone.EU | https://api.eu.amplitude.com/2/httpapi |
useBatch = true (US) | https://api2.amplitude.com/batch |
useBatch = true (EU) | https://api.eu.amplitude.com/batch |
serverUrl set | The URL you set |
The SDK uploads when 30 events (flushQueueSize) are waiting, 30 seconds (flushIntervalMillis) after an event is queued, when the app goes to the background, and when the device reconnects. To send right away, call amplitude.flush().
The request body has this shape:
{
"api_key": "YOUR_API_KEY",
"client_upload_time": "2026-10-26T12:00:12.400Z",
"events": [
{
"event_type": "Sign Up Clicked",
"event_properties": { "plan": "starter" },
"device_id": "3f6c1e2a-...R",
"session_id": 1761480000000,
"time": 1761480012345,
"insert_id": "9c0e...",
"platform": "Android",
"os_name": "android",
"ip": "$remote",
"library": "amplitude-analytics-android/1.37.0"
}
]
}
"ip": "$remote" tells Amplitude to use the request's IP address. To omit it, call trackingOptions.disableIpAddress().
Compressed request bodies
For Amplitude's own endpoints, the SDK gzip-compresses every request body and sends Content-Encoding: gzip. For a custom serverUrl, it sends plain JSON unless you set enableRequestBodyCompression = true. Decompress the body before you read it in a proxy.
The debug log doesn't print request bodies. To inspect request bodies, use one of these methods:
- A local proxy: Set
serverUrlto a proxy you control, such as one running on your development machine, and read the body there. The proxy receives plain JSON unless you turn on compression. - A custom HTTP client: Set
httpClientto anHttpClientInterfacethat logs theeventsstring before it uploads. Go to Use a custom HTTP client.
If no request appears:
| Cause | How to confirm | Fix |
|---|---|---|
| The device is offline | The SDK holds uploads while offline is true. It flushes when the device reconnects. | Expected. Reconnect, or call flush() after the device is online. |
You set offline = true | Your code or configuration sets offline. | Set offline = false. |
| The upload interval hasn't passed | Fewer than 30 events are queued, and less than 30 seconds has passed. | Call amplitude.flush(). |
| The network blocks Amplitude | Logcat shows Request failed: .... | Route events through a proxy. |
Check what Amplitude received
Read the upload result
Pass a callback to track() to get the result for one event, or set callback in the configuration to get every result:
import com.amplitude.core.events.BaseEvent
amplitude.track(BaseEvent().apply { eventType = "Sign Up Clicked" }) { event, status, message ->
Log.d("MyApp", "${event.eventType}: $status $message")
}
The SDK calls the callback only when it finishes with an event, either because Amplitude accepted it or because the SDK dropped it. For errors that the SDK retries, it keeps the event and doesn't call the callback until a later attempt finishes.
| Server response | status and message | What the SDK does |
|---|---|---|
200 | 200, Event sent success. | Removes the event from the queue. |
400, invalid API key | 400, the server error, for example Invalid API key: ... | Drops every event in the request and logs Invalid API key. Don't retry. |
400, some events invalid or silenced | 400, the server error, for the dropped events | Drops the events the server marked, and uploads the rest again. |
400 with no event details, for example from a proxy | No callback | Retries with backoff. |
413 | 413, the server error, only for a single event that's still too large | Splits the request in half and retries. Drops a single event that's still too large. |
429 | No callback | Waits 30 seconds, then retries. |
408, 5xx, and any other status | No callback | Retries with backoff: about 1, 8, 32, and 128 seconds, then every 5 minutes, with no limit. |
A successful response body looks like this:
{
"code": 200,
"events_ingested": 1,
"payload_size_bytes": 412,
"server_upload_time": 1761480012612
}
A 200 response means Amplitude's ingestion API accepted the event. Amplitude can still exclude an accepted event from analysis later, for example when a block filter matches it.
Find the event in Amplitude
- Open User Lookup and search for the event's
user_id, or itsdevice_idif the user is anonymous. - Select the Activity tab and turn on Live event updates.
- Open the event and compare its properties with what your inspection plugin logged.
For events from many users, use the Ingestion Debugger to review accepted and rejected requests for the project. Charts can take a few minutes longer to include new events than User Lookup does.
Was this helpful?
For AI agents: a documentation index is available at /docs/llms.txt. Append .md to any page URL for markdown, or send Accept: text/markdown.