feat(read-api): locale negotiation + scope filtering + multi-locale flatten

ordinis-read-api модуль полностью реализован:

- OrdinisReadApiApplication main + EnableJpaRepositories/EntityScan
- LocaleNegotiator: RFC 7231 / Locale.LanguageRange.parse + Locale.lookup
  по supported_locales справочника, fallback на default_locale,
  поддержка Accept-Language: * (raw mode)
- LocaleAwareJsonFlattener: walk schema_json.properties, для x-localized
  полей в data заменяет {locale:text} на string выбранной локали
  с fallback на default_locale; recursive walk для nested objects
- RecordReadService: read с scope filtering + locale negotiation +
  flatten, метрика ordinis_read_api_query_duration_seconds, scope
  access enforcement (ScopeAccessDeniedException)
- DictionaryReadController: GET /api/v1/{name}/records/{businessKey}
  и list, with @RequestHeader(Accept-Language), Content-Language
  response header, Vary: Accept-Language; query param as_scope для
  v1 (заменим на JWT scope claim позже)
- ReadApiExceptionHandler: 404 NoSuchElement, 403 ScopeAccessDenied,
  400 IllegalArgument, 500 fallback
- application.yml profile-based: dev (port-forward, ddl-auto=none —
  read-api не валидирует schema, это work writer/migrations), k8s
  (Vault role ordinis-read-api, hikari read-only=true)
- logback-spring.xml: MDC включает 'locale' для Loki query

E2E (port 8081 параллельно с writer на 8080):
- GET MOSCOW-1 без Accept-Language → name=Москва-1, Content-Language: ru-RU
- GET MOSCOW-1 с Accept-Language: en-US → name=Moscow-1, Content-Language: en-US
- GET MOSCOW-1 с Accept-Language: fr,en-US;q=0.8,ru;q=0.5 → выбрал en-US (fr нет в supported)
- GET MOSCOW-1 с Accept-Language: * → raw {ru-RU,en-US}, _meta.locale=null
- GET list → массив с flatten для каждой записи
- GET NONEXISTENT → 404 not_found
- GET с as_scope=INTERNAL для PUBLIC записи → 403 scope_access_denied
This commit is contained in:
Zimin A.N.
2026-05-03 13:10:07 +03:00
parent be77c30687
commit 51fcc95818
11 changed files with 675 additions and 0 deletions
+21
View File
@@ -49,5 +49,26 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package cloud.nstart.terravault.ordinis.readapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis")
@EnableJpaRepositories(basePackages = "cloud.nstart.terravault.ordinis")
@EntityScan(basePackages = "cloud.nstart.terravault.ordinis")
public class OrdinisReadApiApplication {
public static void main(String[] args) {
SpringApplication.run(OrdinisReadApiApplication.class, args);
}
}
@@ -0,0 +1,42 @@
package cloud.nstart.terravault.ordinis.readapi.dto;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import org.locationtech.jts.io.WKTWriter;
import java.time.OffsetDateTime;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FlattenedRecordResponse(
UUID id,
String businessKey,
JsonNode data,
String geometryWkt,
DataScope dataScope,
OffsetDateTime validFrom,
OffsetDateTime validTo,
Meta _meta) {
public record Meta(String locale, String defaultLocale, OffsetDateTime asOf) {}
public static FlattenedRecordResponse of(
DictionaryRecord r,
JsonNode flattenedData,
String chosenLocale,
String defaultLocale,
OffsetDateTime asOf) {
String wkt = r.getGeometry() == null ? null : new WKTWriter().write(r.getGeometry());
return new FlattenedRecordResponse(
r.getId(),
r.getBusinessKey(),
flattenedData,
wkt,
r.getDataScope(),
r.getValidFrom(),
r.getValidTo(),
new Meta(chosenLocale, defaultLocale, asOf));
}
}
@@ -0,0 +1,17 @@
package cloud.nstart.terravault.ordinis.readapi.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record PageResponse<T>(
List<T> items,
int total,
int page,
int size) {
public static <T> PageResponse<T> of(List<T> items, int page, int size, int total) {
return new PageResponse<>(items, total, page, size);
}
}
@@ -0,0 +1,82 @@
package cloud.nstart.terravault.ordinis.readapi.locale;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.stereotype.Component;
import java.util.Iterator;
import java.util.Map;
/**
* Сплющивает multi-locale JSON в плоское значение по выбранной локали.
*
* <p>Walk проходит {@code schemaJson.properties}, для каждого property с
* {@code x-localized: true} в {@code data} заменяет object {@code {locale:text}}
* на string выбранной локали. Если выбранной нет — fallback на default.
*
* <p>Если {@code chosenLocale == null} — возвращает data как есть (raw multi-locale).
*/
@Component
public class LocaleAwareJsonFlattener {
private final ObjectMapper objectMapper;
public LocaleAwareJsonFlattener(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* @param schema schema_json справочника
* @param data текущая запись (полный multi-locale)
* @param chosenLocale выбранная локаль, или null для raw
* @param defaultLocale fallback если chosenLocale нет в data
*/
public JsonNode flatten(JsonNode schema, JsonNode data, String chosenLocale, String defaultLocale) {
if (chosenLocale == null) {
return data;
}
if (data == null || !data.isObject()) {
return data;
}
ObjectNode out = data.deepCopy();
walkAndFlatten(schema, out, chosenLocale, defaultLocale);
return out;
}
private void walkAndFlatten(JsonNode schema, ObjectNode data, String locale, String defaultLocale) {
if (schema == null || !schema.isObject()) return;
JsonNode properties = schema.get("properties");
if (properties == null || !properties.isObject()) return;
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
String fieldName = entry.getKey();
JsonNode fieldSchema = entry.getValue();
JsonNode xLocalized = fieldSchema.get("x-localized");
if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
flattenLocalizedField(data, fieldName, locale, defaultLocale);
} else if (fieldSchema.has("properties") && data.has(fieldName) && data.get(fieldName).isObject()) {
walkAndFlatten(fieldSchema, (ObjectNode) data.get(fieldName), locale, defaultLocale);
}
}
}
private void flattenLocalizedField(ObjectNode data, String field, String locale, String defaultLocale) {
JsonNode value = data.get(field);
if (value == null || !value.isObject()) return;
JsonNode picked = value.get(locale);
if (picked == null || picked.isNull()) {
picked = value.get(defaultLocale);
}
if (picked == null || picked.isNull()) {
// ни запрошенной, ни default нет — оставляем null
data.set(field, objectMapper.nullNode());
} else {
data.set(field, picked);
}
}
}
@@ -0,0 +1,56 @@
package cloud.nstart.terravault.ordinis.readapi.locale;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* Locale negotiation per RFC 7231: парсит Accept-Language, lookup'ает по
* supported_locales справочника, fallback на default_locale.
*
* <p>Особенный случай — {@code Accept-Language: *} → возвращаем null, что
* означает «отдай как есть, не сплющивай».
*/
@Component
public class LocaleNegotiator {
/**
* @return chosen locale (BCP 47 tag like "ru-RU"), либо null если клиент
* попросил wildcard "*" (= raw multi-locale в ответе).
*/
public String negotiate(String acceptLanguage, String[] supported, String defaultLocale) {
if (supported == null || supported.length == 0) {
return defaultLocale;
}
if (acceptLanguage == null || acceptLanguage.isBlank()) {
return defaultLocale;
}
if ("*".equals(acceptLanguage.trim())) {
return null; // raw mode
}
try {
List<Locale.LanguageRange> ranges = Locale.LanguageRange.parse(acceptLanguage);
List<Locale> supportedLocales = new ArrayList<>(supported.length);
for (String s : supported) supportedLocales.add(Locale.forLanguageTag(s));
Locale chosen = Locale.lookup(ranges, supportedLocales);
if (chosen != null) {
return chosen.toLanguageTag();
}
} catch (IllegalArgumentException ignore) {
// malformed Accept-Language → fall through to default
}
return defaultLocale;
}
/**
* Поддерживаемые локали справочника как list для удобства логирования.
*/
public List<String> supportedAsList(String[] supported) {
return supported == null ? List.of() : Arrays.asList(supported);
}
}
@@ -0,0 +1,134 @@
package cloud.nstart.terravault.ordinis.readapi.service;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
import com.fasterxml.jackson.databind.JsonNode;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
/**
* Read-side. Negotiate locale → load record from replica с фильтрацией по
* scope → flatten multi-locale → ответ.
*
* <p>Подключение через transactional read-only — Hibernate использует replica
* connection (см. application.yml для read-api).
*/
@Service
public class RecordReadService {
private final DictionaryDefinitionRepository definitionRepository;
private final DictionaryRecordRepository recordRepository;
private final LocaleNegotiator localeNegotiator;
private final LocaleAwareJsonFlattener flattener;
private final Timer queryTimer;
public RecordReadService(
DictionaryDefinitionRepository definitionRepository,
DictionaryRecordRepository recordRepository,
LocaleNegotiator localeNegotiator,
LocaleAwareJsonFlattener flattener,
MeterRegistry meterRegistry) {
this.definitionRepository = definitionRepository;
this.recordRepository = recordRepository;
this.localeNegotiator = localeNegotiator;
this.flattener = flattener;
this.queryTimer = Timer.builder("ordinis_read_api_query_duration_seconds")
.description("Время чтения записи через read-api с locale negotiation")
.register(meterRegistry);
}
public record ReadResult(FlattenedRecordResponse response, String chosenLocale) {}
@Transactional(readOnly = true)
public ReadResult readActive(
String dictionaryName,
String businessKey,
String acceptLanguage,
List<DataScope> allowedScopes,
OffsetDateTime asOf) {
Timer.Sample sample = Timer.start();
try {
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
if (!allowedScopes.contains(def.getScope())) {
throw new ScopeAccessDeniedException(
"Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
}
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
Optional<DictionaryRecord> opt = recordRepository.findActiveAt(def.getId(), businessKey, when);
if (opt.isEmpty()) {
throw new NoSuchElementException(
"No active record for business_key=" + businessKey + " at " + when);
}
DictionaryRecord record = opt.get();
if (!allowedScopes.contains(record.getDataScope())) {
throw new ScopeAccessDeniedException(
"Record scope " + record.getDataScope() + " not in " + allowedScopes);
}
String chosen = localeNegotiator.negotiate(
acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
JsonNode flat = flattener.flatten(def.getSchemaJson(), record.getData(), chosen, def.getDefaultLocale());
return new ReadResult(
FlattenedRecordResponse.of(record, flat, chosen, def.getDefaultLocale(), when),
chosen);
} finally {
sample.stop(queryTimer);
}
}
@Transactional(readOnly = true)
public List<FlattenedRecordResponse> listActive(
String dictionaryName,
String acceptLanguage,
List<DataScope> allowedScopes,
OffsetDateTime asOf) {
DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
.orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
if (!allowedScopes.contains(def.getScope())) {
throw new ScopeAccessDeniedException(
"Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
}
OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
List<DictionaryRecord> records = recordRepository.findActiveByDictionaryAndScopes(
def.getId(), allowedScopes, when);
String chosen = localeNegotiator.negotiate(
acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
return records.stream()
.map(r -> FlattenedRecordResponse.of(
r,
flattener.flatten(def.getSchemaJson(), r.getData(), chosen, def.getDefaultLocale()),
chosen,
def.getDefaultLocale(),
when))
.toList();
}
public static class ScopeAccessDeniedException extends RuntimeException {
public ScopeAccessDeniedException(String message) { super(message); }
}
}
@@ -0,0 +1,87 @@
package cloud.nstart.terravault.ordinis.readapi.web;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Read API для consumers (геопортал, Альтум). Фронт публичный — здесь
* locale negotiation + scope filtering.
*
* <p>Scope authorisation: в v1 берём из query param {@code as_scope} (для
* dev/curl). В prod — из JWT (@nstart/auth) — сделаем через filter позже.
*/
@RestController
@RequestMapping("/api/v1/{dictionaryName}/records")
public class DictionaryReadController {
private final RecordReadService service;
private final Counter scopeDeniedCounter;
public DictionaryReadController(RecordReadService service, MeterRegistry meterRegistry) {
this.service = service;
this.scopeDeniedCounter = Counter.builder("ordinis_scope_access_denied_total")
.description("Попытки чтения недоступного scope (v1 — из query param)")
.register(meterRegistry);
}
@GetMapping("/{businessKey}")
public ResponseEntity<FlattenedRecordResponse> getActive(
@PathVariable String dictionaryName,
@PathVariable String businessKey,
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
@RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
@RequestParam(value = "at", required = false) OffsetDateTime at) {
List<DataScope> scopes = parseScopes(asScope);
var result = service.readActive(dictionaryName, businessKey, acceptLanguage, scopes, at);
HttpHeaders headers = new HttpHeaders();
if (result.chosenLocale() != null) {
headers.add(HttpHeaders.CONTENT_LANGUAGE, result.chosenLocale());
}
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
return new ResponseEntity<>(result.response(), headers, 200);
}
@GetMapping
public ResponseEntity<List<FlattenedRecordResponse>> list(
@PathVariable String dictionaryName,
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
@RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
@RequestParam(value = "at", required = false) OffsetDateTime at) {
List<DataScope> scopes = parseScopes(asScope);
var items = service.listActive(dictionaryName, acceptLanguage, scopes, at);
HttpHeaders headers = new HttpHeaders();
if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) {
headers.add(HttpHeaders.CONTENT_LANGUAGE, items.get(0)._meta().locale());
}
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
return new ResponseEntity<>(items, headers, 200);
}
private List<DataScope> parseScopes(String asScope) {
return Stream.of(asScope.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(s -> DataScope.valueOf(s.toUpperCase()))
.collect(Collectors.toList());
}
}
@@ -0,0 +1,63 @@
package cloud.nstart.terravault.ordinis.readapi.web;
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService.ScopeAccessDeniedException;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micrometer.tracing.Tracer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.OffsetDateTime;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class ReadApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ReadApiExceptionHandler.class);
private final ObjectProvider<Tracer> tracerProvider;
public ReadApiExceptionHandler(ObjectProvider<Tracer> tracerProvider) {
this.tracerProvider = tracerProvider;
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorBody(int status, String code, String message, String traceId, OffsetDateTime timestamp) {}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorBody> onNotFound(NoSuchElementException e) {
return body(HttpStatus.NOT_FOUND, "not_found", e.getMessage());
}
@ExceptionHandler(ScopeAccessDeniedException.class)
public ResponseEntity<ErrorBody> onScope(ScopeAccessDeniedException e) {
return body(HttpStatus.FORBIDDEN, "scope_access_denied", e.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorBody> onBadRequest(IllegalArgumentException e) {
return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
log.error("Unhandled exception in read-api", e);
return body(HttpStatus.INTERNAL_SERVER_ERROR, "internal_error", "Internal server error");
}
private ResponseEntity<ErrorBody> body(HttpStatus status, String code, String message) {
return ResponseEntity.status(status)
.body(new ErrorBody(status.value(), code, message, currentTraceId(), OffsetDateTime.now()));
}
private String currentTraceId() {
Tracer tracer = tracerProvider.getIfAvailable();
if (tracer == null) return null;
var span = tracer.currentSpan();
return span == null ? null : span.context().traceId();
}
}
@@ -0,0 +1,119 @@
spring:
application:
name: ordinis-read-api
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
server:
port: 8081
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,prometheus,refresh,metrics,env,mappings
endpoint:
health:
probes:
enabled: true
show-details: when-authorized
group:
liveness:
include: livenessState
readiness:
include: readinessState,db
prometheus:
metrics:
export:
enabled: true
metrics:
tags:
application: ${spring.application.name}
environment: ${ENVIRONMENT:dev}
otel:
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://tempo.monitoring:4317}
resource:
attributes:
service.name: ${spring.application.name}
service.namespace: ordinis
deployment.environment: ${ENVIRONMENT:dev}
traces:
sampler: parentbased_traceidratio
sampler.arg: ${ORDINIS_TRACING_SAMPLING:0.1}
logging:
level:
root: INFO
cloud.nstart.terravault.ordinis: DEBUG
---
# DEV: подключение к replica через port-forward.
# Для read-api используем `ordinis-postgres-ro` сервис (read-only replica).
# Через port-forward в local dev можно использовать тот же ordinis-postgres-rw
# (single-node cluster, replica = тот же primary), важна только конвенция.
spring:
config:
activate:
on-profile: dev
cloud:
config:
enabled: false
vault:
enabled: false
autoconfigure:
exclude:
- org.springframework.cloud.vault.config.VaultAutoConfiguration
- org.springframework.cloud.vault.config.VaultObservationAutoConfiguration
- org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration
datasource:
url: jdbc:postgresql://${ORDINIS_PG_HOST:localhost}:${ORDINIS_PG_PORT:5433}/${ORDINIS_PG_DB:ordinis}
username: ${ORDINIS_PG_USER:ordinis_app}
password: ${ORDINIS_PG_PASSWORD:}
driver-class-name: org.postgresql.Driver
hikari:
read-only: true
jpa:
hibernate:
ddl-auto: none
properties:
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
open-in-view: false
otel:
sdk:
disabled: true
---
# K8s: подключение к replica + Vault для credentials, Cloud Config для feature flags.
spring:
config:
activate:
on-profile: k8s
import:
- "configserver:${SPRING_CLOUD_CONFIG_URI:http://config-server.ordinis-dev:8888}"
- "vault://"
cloud:
config:
enabled: true
vault:
enabled: true
uri: ${VAULT_URI:http://vault.config:8200}
authentication: KUBERNETES
kubernetes:
role: ordinis-read-api
service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
kv:
enabled: true
backend: secret
application-name: ordinis/cuod
datasource:
hikari:
read-only: true
jpa:
hibernate:
ddl-auto: none
open-in-view: false
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="ordinis-read-api"/>
<springProperty scope="context" name="environment" source="ENVIRONMENT" defaultValue="dev"/>
<appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>scope</includeMdcKeyName>
<includeMdcKeyName>dictionary</includeMdcKeyName>
<includeMdcKeyName>locale</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<includeMdcKeyName>user_id</includeMdcKeyName>
<customFields>{"service":"${appName}","environment":"${environment}"}</customFields>
</encoder>
</appender>
<appender name="STDOUT_HUMAN" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="STDOUT_HUMAN"/>
</root>
<logger name="cloud.nstart.terravault.ordinis" level="DEBUG"/>
</springProfile>
<springProfile name="!dev">
<root level="INFO">
<appender-ref ref="STDOUT_JSON"/>
</root>
<logger name="cloud.nstart.terravault.ordinis" level="DEBUG"/>
</springProfile>
</configuration>