Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: send logs to splunk #366

Merged
merged 2 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading