Skip to content

Error-triggered log capture ​

Verbose SDK logs are valuable after a failed call or session because they show the events that led to the failure. Sending every verbose log in production is usually unnecessary, costly, and a privacy risk. Instead, keep a small sliding window in memory and upload a snapshot only when an error occurs or the user chooses Report an issue.

This pattern:

  1. registers a custom logger at the Verbose level;
  2. redacts and truncates messages before adding them to a buffer capped by entry count;
  3. records the timestamp, level, topic, session ID, and active call ID with each entry;
  4. starts at most one upload at a time;
  5. removes an uploaded snapshot only after the backend accepts it; and
  6. leaves failed entries in the bounded buffer for a later retry, subject to normal oldest-entry eviction.

Obtain consent and define a data policy first

SDK logs can contain identifiers, call metadata, and customer-supplied values. Document what you collect, redact secrets and unnecessary personal data before buffering, ask for consent where required, and give users a clear indication when Report an issue transmits diagnostics.

Implement the client buffer ​

Register custom loggers when you initialize the client. The SDK sends each matching entry to the logger with its level, topic, and message; Android also supplies an optional Throwable. Keep this callback fast and non-blocking: redact and append to memory there, but perform network upload asynchronously outside the callback.

The following examples use the current custom logger API on each platform. Replace the example redactor with an allowlist or redaction rules for your application, and implement the upload callback with your normal authenticated HTTP client.

private data class BufferedLogEntry(
    val sequence: Long,
    val timestampMs: Long,
    val level: String,
    val topic: String,
    val message: String,
    val sessionId: String?,
    val callId: String?,
)

private class BufferedLogUploader(
    private val scope: CoroutineScope,
    private val upload: suspend (List<BufferedLogEntry>) -> Unit,
    private val redact: (String) -> String,
) {
    private val lock = Any()
    private val maxEntries = 500
    private val maxMessageChars = 8_000
    private val buffer = ArrayDeque<BufferedLogEntry>(maxEntries)
    private var nextSequence = 0L
    private var uploading = false
    private var flushPending = false
    private var sessionId: String? = null
    private var callId: String? = null

    val logger: VonageLogger = createVonageLogger(
        name = "BufferedLogger",
        minLogLevel = LoggingLevel.Verbose,
    ) { level, topic, message, _ ->
        synchronized(lock) {
            buffer.addLast(
                BufferedLogEntry(
                    sequence = nextSequence++,
                    timestampMs = System.currentTimeMillis(),
                    level = level.name,
                    topic = topic.name,
                    message = redact(message).take(maxMessageChars),
                    sessionId = sessionId,
                    callId = callId,
                )
            )
            if (buffer.size > maxEntries) buffer.removeFirst()
        }
        if (level == LoggingLevel.Error) flush()
    }

    fun setSessionId(id: String) = synchronized(lock) {
        sessionId = id
    }

    fun setCallId(id: String) = synchronized(lock) {
        callId = id
    }

    fun clearCallId(id: String) = synchronized(lock) {
        if (callId == id) callId = null
    }

    fun flush() {
        val shouldStart = synchronized(lock) {
            when {
                uploading -> {
                    flushPending = true
                    false
                }
                buffer.isEmpty() -> false
                else -> {
                    uploading = true
                    true
                }
            }
        }
        if (!shouldStart) return

        scope.launch {
            var uploadAgain: Boolean
            do {
                val snapshot = synchronized(lock) {
                    flushPending = false
                    buffer.toList()
                }
                val lastSequence = snapshot.last().sequence

                try {
                    upload(snapshot)
                    synchronized(lock) {
                        while (buffer.peekFirst()?.sequence?.let { it <= lastSequence } == true) {
                            buffer.removeFirst()
                        }
                    }
                } catch (error: Exception) {
                    // Keep entries in the bounded buffer for a later retry.
                    System.err.println("Log upload failed: ${error.message}")
                }

                uploadAgain = synchronized(lock) {
                    if (flushPending && buffer.isNotEmpty()) {
                        true
                    } else {
                        uploading = false
                        false
                    }
                }
            } while (uploadAgain)
        }
    }
}

private fun createVoiceClientWithBufferedLogs(
    context: Context,
    scope: CoroutineScope,
    upload: suspend (List<BufferedLogEntry>) -> Unit,
): Pair<VoiceClient, BufferedLogUploader> {
    val logs = BufferedLogUploader(scope, upload, ::redactSdkLog)
    val client = VoiceClient(
        context,
        VGClientInitConfig(
            disableInternalLogger = true,
            customLoggers = listOf(logs.logger),
        )
    )
    client.setCallInviteListener { callId, _, _ -> logs.setCallId(callId) }
    client.setCallInviteCancelListener { callId, _ -> logs.clearCallId(callId) }
    client.setOnCallHangupListener { callId, _, _ -> logs.clearCallId(callId) }
    client.setSessionErrorListener { logs.flush() }
    client.setOnCallMediaErrorListener { callId, _ ->
        logs.setCallId(callId)
        logs.flush()
    }
    return client to logs
}

private suspend fun startSession(
    client: VoiceClient,
    logs: BufferedLogUploader,
    jwt: String,
) {
    logs.setSessionId(client.createSession(jwt))
}

private suspend fun startCall(
    client: VoiceClient,
    logs: BufferedLogUploader,
    context: Map<String, String>,
): String = client.serverCall(context).also(logs::setCallId)

private fun redactSdkLog(message: String): String =
    message.replace(Regex("Bearer\\s+\\S+", RegexOption.IGNORE_CASE), "Bearer [REDACTED]")

// Call this from a user-confirmed "Report an issue" action.
private fun reportIssue(logs: BufferedLogUploader) {
    logs.flush()
}

disableInternalLogger prevents the SDK's built-in logger from writing the same verbose stream to the console. It does not disable the custom logger. Until flush() runs, entries remain only in process memory: they are not persisted or transmitted, and they disappear if the page reloads or the app terminates.

The sample tracks one active call ID. If your application supports concurrent calls, keep call-scoped buffers or enrich entries in the call-specific code path rather than using a single callId field.

Update correlation IDs ​

Set the session ID immediately after createSession succeeds. Set the call ID when an outbound call is created or an inbound invite arrives, then clear it only when that same call ends or is cancelled. Keeping the IDs on each entry—not only on the upload request—makes mixed pre-call, in-call, and post-call logs easier to interpret.

Flush automatically or on demand ​

The logger calls flush() when it receives an Error entry. The examples also flush on session and call-media error callbacks, so capture does not depend on a particular internal log line. Your application can call the same method from a user-confirmed Report an issue action. These paths share the upload guard, so repeated errors and button taps cannot create concurrent duplicate uploads. A trigger received during an upload is coalesced into one follow-up attempt.

A successful upload removes only entries included in its snapshot; logs produced during the request remain buffered. After a failed upload, entries remain available for the next error or manual retry until the sliding-window cap evicts the oldest ones. This bounded-loss policy prevents a prolonged outage from growing memory indefinitely; choose the entry and message limits for the diagnostic window your application can afford.

Send logs through your backend ​

Do not embed object-storage credentials or pre-authorized unrestricted upload credentials in an app. Send the batch to a customer-controlled HTTPS endpoint using the user's existing session or a short-lived, narrowly scoped upload token. The backend should authenticate the caller and construct the object key itself.

The trust boundary can look like this (adapt the framework and storage client to your backend):

ts
app.post(
  '/api/sdk-logs',
  authenticateUser,
  jsonBody({ limitBytes: 1_000_000 }),
  async (request, response) => {
    const entries = validateLogEntries(request.body.entries, {
      maxEntries: 500,
      maxMessageBytes: 8_000
    });
    const safeEntries = redactServerSide(entries);

    // Never accept an object key or tenant identifier from the request body.
    const tenant = safeObjectSegment(request.user.tenantId);
    const reportId = crypto.randomUUID();
    const key = `sdk-logs/${tenant}/${reportId}.json`;
    const sessionIds = [...new Set(safeEntries.flatMap(
      (entry) => entry.sessionId ? [entry.sessionId] : []
    ))];
    const callIds = [...new Set(safeEntries.flatMap(
      (entry) => entry.callId ? [entry.callId] : []
    ))];

    await objectStorage.put(key, JSON.stringify(safeEntries), {
      contentType: 'application/json',
      serverSideEncryption: true,
      retentionDays: 30
    });
    await diagnosticsIndex.put({
      tenant,
      reportId,
      key,
      sessionIds,
      callIds,
      expiresAt: addDays(new Date(), 30)
    });
    response.status(204).end();
  }
);

At minimum, the endpoint should:

  • require authentication and authorize the caller for the tenant or application;
  • enforce HTTPS, request-size, entry-count, field-length, and rate limits;
  • reject unknown fields and malformed timestamps, levels, topics, and identifiers;
  • repeat redaction server-side rather than trusting the client alone;
  • generate storage paths server-side and maintain a tenant-scoped index for every represented session and call ID;
  • encrypt objects in transit and at rest;
  • restrict read access to the support staff and services that need it;
  • record access in an audit trail; and
  • expire objects automatically under a documented retention policy.

Return a non-success status when storage fails so the client keeps the batch. Do not retry indefinitely in the logger callback; use the next error, a manual report, or a bounded application retry policy.

Retrieve a troubleshooting batch ​

Store each batch under a server-generated report ID and index that report under every distinct session and call ID represented by its entries. Support tooling can then find a mixed pre-call, in-call, or post-call batch using any correlation ID supplied with the incident. Scope index queries to the authenticated tenant, apply the same retention deadline to the object and index record, and never expose raw object-storage access to support clients.

Use infrequent-access storage only after checking its minimum retention period and retrieval charges against your deletion policy. Lifecycle expiration, compression, and uploading only on a configured trigger usually have a larger cost impact than choosing a storage class alone.

Built with VitePress.