Skip to content

Commit

Permalink
feat: send logs to splunk (#366)
Browse files Browse the repository at this point in the history
  • Loading branch information
mghilardelli authored Nov 8, 2024
1 parent 9e5bac2 commit d5a4d0f
Show file tree
Hide file tree
Showing 15 changed files with 161 additions and 107 deletions.
4 changes: 4 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ data class LogEntry(
private val level: LogLevel,
private val metadata: Map<String, String>? = emptyMap()
) {
override fun toString(): String {
return "${time}\t${level}\t${source}\t${message} ${metadata}"
fun toSplunkRequest(): SplunkRequest {
val fields = metadata?.toMutableMap() ?: mutableMapOf()
fields["level"] = level.name
return SplunkRequest(
event = message,
fields = fields,
source = source,
time = time,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,17 @@ import ch.sbb.backend.domain.tenancy.ConfigTenantService
import ch.sbb.backend.domain.tenancy.TenantId
import ch.sbb.backend.infrastructure.configuration.LogDestination
import org.springframework.stereotype.Service
import java.util.*

@Service
class MultitenantLogService(private val tenantService: ConfigTenantService) {
private val logServices: EnumMap<LogDestination, LogService> =
EnumMap(LogDestination::class.java)
class MultitenantLogService(
private val tenantService: ConfigTenantService,
private val splunkLogService: SplunkLogService
) {

fun logs(logs: List<LogEntryRequest>) {
getLogService(TenantContext.current().tenantId)?.logs(logs.map {
getLogService(TenantContext.current().tenantId).logs(logs.map {
LogEntry(
it.time,
it.source,
it.message,
level(it.level),
it.metadata
it.time, it.source, it.message, level(it.level), it.metadata
)
})
}
Expand All @@ -37,21 +33,10 @@ class MultitenantLogService(private val tenantService: ConfigTenantService) {
}
}

private fun getLogService(tenantId: TenantId): LogService? {
private fun getLogService(tenantId: TenantId): LogService {
val logDestination = tenantService.getById(tenantId).logDestination
if (logServices[logDestination] != null) {
return logServices[logDestination]
}
val logService: LogService = createLogService(logDestination)
logServices[logDestination] = logService
return logService
}

private fun createLogService(logDestination: LogDestination?): LogService {
return when (logDestination) {
LogDestination.CONSOLE -> ConsoleLogService()
LogDestination.SPLUNK -> SplunkLogService()
else -> ConsoleLogService()
LogDestination.SPLUNK -> splunkLogService
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
package ch.sbb.backend.domain.logging

import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.WebClientResponseException
import org.springframework.web.server.ResponseStatusException

/** example of a log service implementation without the expected behaviour */
class SplunkLogService: LogService {
@Service
class SplunkLogService(
@Value("\${splunk.url}") private val url: String,
@Value("\${splunk.token}") private val token: String
) : LogService {
private val log = LoggerFactory.getLogger(SplunkLogService::class.java)
private val webClient: WebClient = WebClient.create(url)

override fun logs( logEntries: List<LogEntry>) {
logEntries.forEach { log.info("SPLUNK: $it") }
// todo: instead of logging to console, log to splunk
override fun logs(logEntries: List<LogEntry>) {
webClient.post()
.headers { it["Authorization"] = "Splunk $token" }
.bodyValue(logEntries.map { it.toSplunkRequest() })
.retrieve()
.bodyToMono(Object::class.java)
.doOnError(WebClientResponseException::class.java) {
log.error("Error sending logs to Splunk status=${it.statusCode} body=${it.responseBodyAsString}")
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR)
}
.block()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ch.sbb.backend.domain.logging

import com.fasterxml.jackson.annotation.JsonInclude
import java.time.OffsetDateTime

data class SplunkRequest(
val event: String,
val fields: Map<String, String>,
@JsonInclude(JsonInclude.Include.NON_NULL)
val host: String? = null,
@JsonInclude(JsonInclude.Include.NON_NULL)
val index: String? = null,
val source: String,
val time: OffsetDateTime,
@JsonInclude(JsonInclude.Include.NON_NULL)
val sourcetype: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class ConfigTenantService(private val tenantConfig: TenantConfig) : TenantServic
}

override fun getById(tenantId: TenantId): Tenant {
return tenantConfig.tenants.stream().filter { t -> tenantId == TenantId(t.id!!) }
return tenantConfig.tenants.stream().filter { t -> tenantId == TenantId(t.id) }
.findAny()
.orElseThrow { IllegalArgumentException("unknown tenant") }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package ch.sbb.backend.infrastructure.configuration

enum class LogDestination {
CONSOLE,
SPLUNK
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package ch.sbb.backend.infrastructure.configuration

data class Tenant(
var name: String? = null,
var id: String? = null,
var jwkSetUri: String? = null,
var issuerUri: String? = null,
var logDestination: LogDestination? = null
var name: String,
var id: String,
var jwkSetUri: String,
var issuerUri: String,
var logDestination: LogDestination
)
6 changes: 5 additions & 1 deletion backend/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ auth:
id: 2cda5d11-f0ac-46b3-967d-af1b2e1bd01a
issuer-uri: https://login.microsoftonline.com/2cda5d11-f0ac-46b3-967d-af1b2e1bd01a/v2.0
jwk-set-uri: https://login.microsoftonline.com/2cda5d11-f0ac-46b3-967d-af1b2e1bd01a/discovery/v2.0/keys
log-destination: console
log-destination: splunk
- name: sob
id: d653d01f-17a4-48a1-9aab-b780b61b4273
issuer-uri: https://login.microsoftonline.com/d653d01f-17a4-48a1-9aab-b780b61b4273/v2.0
jwk-set-uri: https://login.microsoftonline.com/d653d01f-17a4-48a1-9aab-b780b61b4273/discovery/v2.0/keys
log-destination: splunk

splunk:
url: ${SPLUNK_HEC_URL}
token: ${SPLUNK_HEC_TOKEN}
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
package ch.sbb.backend.application.rest

import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import ch.sbb.backend.domain.logging.LogEntry
import ch.sbb.backend.domain.logging.LogLevel
import ch.sbb.backend.domain.logging.SplunkLogService
import org.junit.jupiter.api.Test
import org.mockito.Mockito.verify
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.time.OffsetDateTime

@SpringBootTest
@AutoConfigureMockMvc
Expand All @@ -23,18 +24,8 @@ class LoggingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc

private val originalOut = System.out
private val outputStreamCaptor = ByteArrayOutputStream()

@BeforeEach
fun setUp() {
System.setOut(PrintStream(outputStreamCaptor))
}

@AfterEach
fun tearDown() {
System.setOut(originalOut)
}
@MockBean
private lateinit var splunkLogService: SplunkLogService

@Test
fun `should log messages with seconds since epoch timestamp`() {
Expand All @@ -57,7 +48,7 @@ class LoggingControllerTest {
jwt.claims { claims ->
claims.put(
"tid",
"2cda5d11-f0ac-46b3-967d-af1b2e1bd01a"
"3409e798-d567-49b1-9bae-f0be66427c54"
)
}
}
Expand All @@ -68,8 +59,16 @@ class LoggingControllerTest {
)
.andExpect(status().isOk)

val output = outputStreamCaptor.toString()
assertTrue(output.contains("2024-10-05T18:58:19.452+02:00\tINFO\titest\tmy message {}"))
val expectedLogs = listOf(
LogEntry(
OffsetDateTime.parse("2024-10-05T18:58:19.452+02:00"),
"itest",
"my message",
LogLevel.INFO
)
)

verify(splunkLogService).logs(expectedLogs)
}

@Test
Expand Down Expand Up @@ -103,7 +102,7 @@ class LoggingControllerTest {
jwt.claims { claims ->
claims.put(
"tid",
"d653d01f-17a4-48a1-9aab-b780b61b4273"
"3409e798-d567-49b1-9bae-f0be66427c54"
)
}
}
Expand All @@ -114,9 +113,23 @@ class LoggingControllerTest {
)
.andExpect(status().isOk)

val output = outputStreamCaptor.toString()
assertTrue(output.lines()[0].contains("SPLUNK: 2024-10-01T14:34:56.789+02:00\tERROR\titest\tmy message {key1=value1, key2=value2}"))
assertTrue(output.lines()[1].contains("SPLUNK: 2024-10-01T14:36:12.546+02:00\tWARNING\titest\tmy warning {}"))
val expectedLogs = listOf(
LogEntry(
OffsetDateTime.parse("2024-10-01T14:34:56.789+02:00"),
"itest",
"my message",
LogLevel.ERROR,
mapOf("key1" to "value1", "key2" to "value2")
),
LogEntry(
OffsetDateTime.parse("2024-10-01T14:36:12.546+02:00"),
"itest",
"my warning",
LogLevel.WARNING
)
)

verify(splunkLogService).logs(expectedLogs)
}

@Test
Expand All @@ -139,7 +152,7 @@ class LoggingControllerTest {
jwt.claims { claims ->
claims.put(
"tid",
"2cda5d11-f0ac-46b3-967d-af1b2e1bd01a"
"3409e798-d567-49b1-9bae-f0be66427c54"
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,31 @@ import ch.sbb.backend.domain.tenancy.ConfigTenantService
import ch.sbb.backend.domain.tenancy.TenantId
import ch.sbb.backend.infrastructure.configuration.LogDestination
import ch.sbb.backend.infrastructure.configuration.Tenant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.mockito.Mockito.*
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.time.Instant
import java.time.OffsetDateTime
import java.util.*

class MultitenantLogServiceTest {

private lateinit var tenantService: ConfigTenantService
private lateinit var sut: MultitenantLogService

private val originalOut = System.out
private val outputStreamCaptor = ByteArrayOutputStream()
private lateinit var tenantService: ConfigTenantService
private lateinit var splunkLogService: SplunkLogService

private val tid = UUID.randomUUID().toString()

@BeforeEach
fun setUp() {
tenantService = mock(ConfigTenantService::class.java)
sut = MultitenantLogService(tenantService)
splunkLogService = mock(SplunkLogService::class.java)
sut = MultitenantLogService(tenantService, splunkLogService)
val securityContext: SecurityContext = mock(SecurityContext::class.java)
System.setOut(PrintStream(outputStreamCaptor))

val jwt = Jwt(
"token",
Instant.now(),
Expand All @@ -53,26 +45,21 @@ class MultitenantLogServiceTest {
}

@Test
fun `should log messages to console`() {
fun `should log messages to splunk`() {
val tenantId = TenantId(tid)
val tenantConfig = Tenant().apply {
logDestination = LogDestination.CONSOLE
}
val tenantConfig = Tenant("test", "10", "", "", LogDestination.SPLUNK)
`when`(tenantService.getById(tenantId)).thenReturn(tenantConfig)

val timestamp = OffsetDateTime.now()
val timestamp = OffsetDateTime.now()
val logEntries = listOf(
LogEntryRequest(timestamp, "source", "message", LogLevelRequest.INFO)
)

sut.logs(logEntries)

val output = outputStreamCaptor.toString()
assertTrue(output.contains("${timestamp.toString()}\tINFO\tsource\tmessage {}\n"))
}

@AfterEach
fun tearDown() {
System.setOut(originalOut)
val expectedLogs = listOf(
LogEntry(timestamp, "source", "message", LogLevel.INFO)
)
verify(splunkLogService, times(1)).logs(expectedLogs)
}
}
Loading

0 comments on commit d5a4d0f

Please sign in to comment.