LogTide
Framework
Easy

Spring Boot and Kotlin Integration

Send structured logs from Spring Boot applications to LogTide with the native Kotlin SDK, middleware support, and coroutines.

Native Kotlin SDK Spring middleware Coroutines support Automatic batching

LogTide integrates with Spring Boot through the native Kotlin SDK, providing structured logging with automatic batching, retry logic, circuit breaker pattern, and seamless integration with Spring’s ecosystem.

Why use LogTide with Spring Boot?

  • Native Kotlin SDK: Full coroutines support with suspend functions
  • Spring middleware: Automatic HTTP request/response logging
  • Automatic batching: Configurable batch size and flush intervals
  • Actuator ready: Health checks and metrics integration out of the box
  • Kotlin-first: Full Kotlin support with coroutines-aware logging
  • Zero code changes: Configure in XML, no application code modifications

Prerequisites

  • Java 17+ or Kotlin 1.9+
  • Spring Boot 3.x
  • Gradle or Maven build system
  • LogTide instance with API key

Installation

Gradle (Kotlin DSL)

// build.gradle.kts
dependencies {
    implementation("io.github.logtide-dev:logtide-sdk-kotlin:0.2.0")
}

Gradle (Groovy)

// build.gradle
dependencies {
    implementation 'io.github.logtide-dev:logtide-sdk-kotlin:0.2.0'
}

Maven

<!-- pom.xml -->
<dependency>
    <groupId>io.github.logtide-dev</groupId>
    <artifactId>logtide-sdk-kotlin</artifactId>
    <version>0.2.0</version>
</dependency>

Quick Start (5 minutes)

1. Configure the LogTide Client

Create a Spring configuration bean:

// LogTideConfig.kt
import dev.logtide.sdk.LogTideClient
import dev.logtide.sdk.models.LogTideClientOptions
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import jakarta.annotation.PreDestroy

@Configuration
class LogTideConfig {

    @Value("\${logtide.api-url:https://api.logtide.dev}")
    private lateinit var apiUrl: String

    @Value("\${logtide.api-key}")
    private lateinit var apiKey: String

    private var client: LogTideClient? = null

    @Bean
    fun logTideClient(): LogTideClient {
        client = LogTideClient(
            LogTideClientOptions(
                apiUrl = apiUrl,
                apiKey = apiKey,
                batchSize = 50,
                flushIntervalMs = 5000,
                defaultService = "spring-app"
            )
        )
        return client!!
    }

    @PreDestroy
    fun cleanup() {
        client?.close()
    }
}

2. Configure Environment Variables

# application.yml
logtide:
  api-url: https://api.logtide.dev
  api-key: ${LOGTIDE_API_KEY}

Or in application.properties:

logtide.api-url=https://api.logtide.dev
logtide.api-key=${LOGTIDE_API_KEY}

3. Start Logging

// UserService.kt
import dev.logtide.sdk.LogTideClient
import org.springframework.stereotype.Service

@Service
class UserService(private val logtide: LogTideClient) {

    fun createUser(email: String): User {
        logtide.info("user-service", "Creating user", mapOf("email" to email))

        val user = userRepository.save(User(email = email))

        logtide.info("user-service", "User created successfully", mapOf(
            "userId" to user.id,
            "email" to email
        ))

        return user
    }

    fun handleError(e: Exception) {
        logtide.error("user-service", "Operation failed", e)
    }
}
// Java
import dev.logtide.sdk.LogTideClient;
import org.springframework.stereotype.Service;
import java.util.Map;

@Service
public class UserService {
    private final LogTideClient logtide;

    public UserService(LogTideClient logtide) {
        this.logtide = logtide;
    }

    public User createUser(String email) {
        logtide.info("user-service", "Creating user", Map.of("email", email));

        User user = userRepository.save(new User(email));

        logtide.info("user-service", "User created successfully", Map.of(
            "userId", user.getId(),
            "email", email
        ));

        return user;
    }
}

MDC Context Propagation

MDC (Mapped Diagnostic Context) allows you to attach contextual data to all log messages within a request:

Request Filter for Trace IDs

// TraceIdFilter.kt
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.util.UUID

@Component
class TraceIdFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val traceId = request.getHeader("X-Trace-ID")
            ?: UUID.randomUUID().toString()

        try {
            MDC.put("traceId", traceId)
            MDC.put("method", request.method)
            MDC.put("uri", request.requestURI)
            MDC.put("remoteAddr", request.remoteAddr)

            response.setHeader("X-Trace-ID", traceId)
            filterChain.doFilter(request, response)
        } finally {
            MDC.clear()
        }
    }
}

Controller with MDC

// UserController.kt
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {

    private val logger = LoggerFactory.getLogger(javaClass)

    @PostMapping
    fun createUser(@RequestBody request: CreateUserRequest): User {
        // Add user context to MDC
        MDC.put("action", "createUser")

        logger.info("Creating new user")

        val user = userService.createUser(request.email)

        // Add user ID after creation
        MDC.put("userId", user.id.toString())

        logger.info("User created successfully")

        return user
    }
}

All logs will now include:

{
  "level": "INFO",
  "message": "User created successfully",
  "traceId": "abc-123-def",
  "method": "POST",
  "uri": "/api/users",
  "action": "createUser",
  "userId": "42"
}

WebFlux Reactive Logging

For Spring WebFlux applications, use reactor context propagation:

Reactive MDC Filter

// ReactiveTraceFilter.kt
import org.slf4j.MDC
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilter
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import reactor.util.context.Context
import java.util.UUID

@Component
class ReactiveTraceFilter : WebFilter {

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        val traceId = exchange.request.headers.getFirst("X-Trace-ID")
            ?: UUID.randomUUID().toString()

        val contextMap = mapOf(
            "traceId" to traceId,
            "method" to exchange.request.method.name(),
            "uri" to exchange.request.path.value()
        )

        return chain.filter(exchange)
            .contextWrite { ctx ->
                ctx.put("logtide-mdc", contextMap)
            }
            .doOnEach { signal ->
                signal.contextView.getOrEmpty<Map<String, String>>("logtide-mdc")
                    .ifPresent { mdc ->
                        mdc.forEach { (key, value) -> MDC.put(key, value) }
                    }
            }
            .doFinally {
                MDC.clear()
            }
    }
}

Reactive Controller

// ReactiveUserController.kt
import kotlinx.coroutines.slf4j.MDCContext
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono

@RestController
@RequestMapping("/api/users")
class ReactiveUserController(private val userService: UserService) {

    private val logger = LoggerFactory.getLogger(javaClass)

    @GetMapping("/{id}")
    suspend fun getUser(@PathVariable id: Long): User = withContext(MDCContext()) {
        logger.info("Fetching user")
        userService.findById(id)
    }

    @PostMapping
    fun createUser(@RequestBody request: CreateUserRequest): Mono<User> {
        return Mono.fromCallable {
            logger.info("Creating user")
            userService.createUser(request.email)
        }.doOnSuccess { user ->
            logger.info("User created: ${user.id}")
        }
    }
}

Structured Logging with Kotlin

Use structured arguments for better queryability:

// Using kotlin-logging library
import io.github.oshai.kotlinlogging.KotlinLogging

private val logger = KotlinLogging.logger {}

class OrderService {
    fun processOrder(order: Order) {
        logger.info {
            "Processing order" to mapOf(
                "orderId" to order.id,
                "amount" to order.total,
                "currency" to order.currency,
                "items" to order.items.size
            )
        }

        try {
            paymentService.charge(order)
            logger.info { "Order payment successful" }
        } catch (e: PaymentException) {
            logger.error(e) {
                "Payment failed" to mapOf(
                    "orderId" to order.id,
                    "errorCode" to e.code
                )
            }
            throw e
        }
    }
}

Actuator Integration

Expose logging configuration via Spring Boot Actuator:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,loggers,metrics
  endpoint:
    health:
      show-details: when_authorized

logging:
  level:
    root: INFO
    com.yourcompany: DEBUG
    dev.logtide: DEBUG

Custom Health Indicator

// LogTideHealthIndicator.kt
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.stereotype.Component

@Component
class LogTideHealthIndicator : HealthIndicator {

    override fun health(): Health {
        return try {
            // Check LogTide connectivity
            val healthy = checkLogTideConnection()
            if (healthy) {
                Health.up()
                    .withDetail("status", "Connected")
                    .build()
            } else {
                Health.down()
                    .withDetail("status", "Disconnected")
                    .build()
            }
        } catch (e: Exception) {
            Health.down(e).build()
        }
    }

    private fun checkLogTideConnection(): Boolean {
        // Implementation depends on LogTide SDK
        return true
    }
}

Docker Setup

Multi-Stage Build

# Dockerfile
FROM gradle:8-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY src ./src
RUN gradle build -x test --no-daemon

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

COPY --from=build /app/build/libs/*.jar app.jar

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget -q --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]

Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=production
      - LOGTIDE_API_URL=${LOGTIDE_API_URL}
      - LOGTIDE_API_KEY=${LOGTIDE_API_KEY}
      - JAVA_OPTS=-Xmx512m -Xms256m
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    depends_on:
      - postgres

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=myapp
      - POSTGRES_PASSWORD=secret
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Kubernetes Deployment

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
    spec:
      containers:
        - name: app
          image: your-registry/spring-app:latest
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "kubernetes"
            - name: LOGTIDE_API_URL
              value: "https://api.logtide.dev"
            - name: LOGTIDE_API_KEY
              valueFrom:
                secretKeyRef:
                  name: logtide-secrets
                  key: api-key
            - name: JAVA_OPTS
              value: "-Xmx512m -Xms256m -XX:+UseG1GC"
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "1000m"
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 5
---
apiVersion: v1
kind: Secret
metadata:
  name: logtide-secrets
type: Opaque
data:
  api-key: <base64-encoded-api-key>

Verification

Test logging is working:

// HealthController.kt
@RestController
class HealthController {

    private val logger = LoggerFactory.getLogger(javaClass)

    @GetMapping("/test-logging")
    fun testLogging(): Map<String, String> {
        logger.debug("Debug message from test endpoint")
        logger.info("Info message from test endpoint")
        logger.warn("Warning message from test endpoint")
        logger.error("Error message from test endpoint")

        return mapOf("status" to "Logs sent to LogTide")
    }
}
curl http://localhost:8080/test-logging

Check LogTide dashboard for logs with your application context.

Performance Impact

Benchmarks from production Spring Boot application (1000 RPS):

MetricWithout LogTideWith LogTide (Async)Overhead
Avg latency25ms25.5ms+2%
P99 latency85ms87ms+2.4%
Memory usage450MB470MB+4.4%
CPU usage35%36%+2.9%

Notes:

  • AsyncAppender ensures logging never blocks request processing
  • Queue size of 1024 handles burst traffic without drops
  • G1GC recommended for predictable pause times

Troubleshooting

Logs not appearing in LogTide

  1. Check client initialization:
// Enable debug logging
val client = LogTideClient(
    LogTideClientOptions(
        apiUrl = apiUrl,
        apiKey = apiKey,
        debug = true  // Enable internal logging
    )
)
  1. Test connectivity:
curl -H "X-API-Key: $LOGTIDE_API_KEY" \
     https://api.logtide.dev/api/v1/ingest \
     -d '{"logs":[{"service":"test","level":"info","message":"test"}]}'
  1. Verify API key format: API keys should start with lp_ prefix.

Trace context not propagating

For async/reactive applications, ensure context propagation is configured:

// For Kotlin coroutines - use withContext to propagate trace ID
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.7.3")

High memory usage

Reduce batch size and buffer limits in your configuration:

val client = LogTideClient(
    LogTideClientOptions(
        apiUrl = apiUrl,
        apiKey = apiKey,
        batchSize = 20,           // Smaller batches
        flushIntervalMs = 2000,   // More frequent flushes
        maxBufferSize = 500       // Limit buffer size
    )
)

Next Steps