Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ object Routes {
const val ENTITY_STATE_AT_SEQUENCE = "entity-state-at-sequence"
}

object Model {
const val REGISTERED_ENTITIES = "model-registered-entities"
const val DOMAIN_EVENTS = "model-domain-events"
const val ENTITY_STATE_AT_SEQUENCE = "model-entity-state-at-sequence"
const val REPLAY_TIMELINE = "model-replay-timeline"
}

object MessageFlow {
const val STATS = "message-flow-stats"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ data class SupportedFeatures(
val clientStatusUpdates: Boolean? = false,
/* Whether the application has the entitlement manager configured, allowing it to receive licenses */
val licenseEntitlement: Boolean? = false,
/* Whether the client supports model inspection (AF5 StateManager-based entity inspection). */
val modelInspection: Boolean? = false,
Comment thread
stefanmirkovic marked this conversation as resolved.
)

data class Versions(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2022-2026. AxonIQ B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.axoniq.platform.framework.api

data class RegisteredEntitiesResult(
val entities: List<RegisteredEntityInfo>
)

data class RegisteredEntityInfo(
val entityType: String,
/**
* All id types registered for this entity. AF5 entities can be addressed by multiple
* id types (e.g. one per command in a build agent), each producing different criteria.
* The frontend should let the user pick which id type to query against.
*/
val idTypes: List<IdType>,
)

data class IdType(
/** Fully qualified Java type name of the id class. */
val type: String,
/**
* Structural descriptors of the id class's properties. Empty for "simple" types
* (String, primitives, UUID, etc.) — frontend renders a single text input. Populated
* for compound types (records / data classes / plain objects) — frontend renders one
* input per descriptor and sends the entityId as a JSON object keyed by descriptor names.
* Only 1-deep properties are described; nested objects are exposed as type "object" and
* left for the user to provide as raw JSON.
*/
val idFields: List<IdFieldDescriptor> = emptyList(),
)

data class IdFieldDescriptor(
val name: String,
/** Normalized form-friendly type: "string", "number", "boolean", "uuid", or "object". */
val type: String,
/** Fully qualified Java type name, useful for diagnostics / future extensions. */
val javaType: String,
)

data class ModelDomainEventsQuery(
val entityType: String,
val entityId: String,
/** FQ Java type name of the id type the user selected (must match one of [RegisteredEntityInfo.idTypes].type). */
val idType: String,
val page: Int = 0,
val pageSize: Int = 10,
)

data class ModelEntityStateAtSequenceQuery(
val entityType: String,
val entityId: String,
/** FQ Java type name of the id type the user selected. */
val idType: String,
val maxSequenceNumber: Long = 0,
)

data class ModelTimelineQuery(
val entityType: String,
val entityId: String,
/** FQ Java type name of the id type the user selected. */
val idType: String,
val offset: Int = 0,
val limit: Int = 100,
)

data class ModelTimelineResult(
val entityType: String,
val entityId: String,
val entries: List<ModelTimelineEntry>,
val offset: Int = 0,
val totalEvents: Int,
val truncated: Boolean,
)

data class ModelTimelineEntry(
val sequenceNumber: Long,
/**
* ISO-8601 formatted timestamp (from [java.time.Instant.toString]).
* String is used here — instead of [java.time.Instant] — to avoid ambiguity in how
* the different serializers (CBOR on the RSocket leg, Jackson on the query-handler leg)
* encode the Instant: some emit an epoch-seconds number, which the frontend would
* then incorrectly treat as milliseconds.
*/
val timestamp: String,
val eventType: String,
val eventPayload: String?,
val stateBefore: String?,
val stateAfter: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.axoniq.platform.framework;

import io.axoniq.platform.framework.api.DomainEventAccessMode;
import org.axonframework.common.BuilderUtils;

import java.lang.management.ManagementFactory;
Expand All @@ -41,6 +42,8 @@ public class AxoniqPlatformConfiguration {
private ScheduledExecutorService reportingTaskExecutor;
private Integer reportingThreadPoolSize = 2;

private DomainEventAccessMode domainEventAccessMode = DomainEventAccessMode.NONE;

/**
* Constructor to instantiate a {@link AxoniqPlatformConfiguration} based on the fields contained in the
* {@link AxoniqPlatformConfiguration}. Requires the {@code environmentId}, {@code accessToken} and
Expand Down Expand Up @@ -148,6 +151,21 @@ public AxoniqPlatformConfiguration secure(boolean secure) {
return this;
}

/**
* Sets the access mode applied to the model-inspection routes (domain events list, entity
* state at sequence, timeline replay). The default is {@link DomainEventAccessMode#NONE},
* which leaves the routes registered but redacts payloads and state — the operator must
* opt in to expose them, matching the AF4 console-framework-client contract.
*
* @param domainEventAccessMode The mode, never {@code null}
* @return The builder for fluent interfacing
*/
public AxoniqPlatformConfiguration domainEventAccessMode(DomainEventAccessMode domainEventAccessMode) {
BuilderUtils.assertNonNull(domainEventAccessMode, "Axoniq Platform domainEventAccessMode may not be null");
this.domainEventAccessMode = domainEventAccessMode;
return this;
}

public ScheduledExecutorService getReportingTaskExecutor() {
if (reportingTaskExecutor == null) {
reportingTaskExecutor = Executors.newScheduledThreadPool(reportingThreadPoolSize);
Expand Down Expand Up @@ -186,4 +204,8 @@ public String getHostname() {
public Long getInitialDelay() {
return initialDelay;
}

public DomainEventAccessMode getDomainEventAccessMode() {
return domainEventAccessMode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.axoniq.platform.framework;

import io.axoniq.platform.framework.api.DomainEventAccessMode;
import io.axoniq.platform.framework.application.ApplicationMetricRegistry;
import io.axoniq.platform.framework.application.ApplicationMetricReporter;
import io.axoniq.platform.framework.application.ApplicationReportCreator;
Expand Down Expand Up @@ -63,6 +64,10 @@ public void enhance(ComponentRegistry registry) {
return;
}
registry
.registerComponent(ComponentDefinition
.ofType(DomainEventAccessMode.class)
.withBuilder(c -> c.getComponent(AxoniqPlatformConfiguration.class)
.getDomainEventAccessMode()))
.registerComponent(ComponentDefinition
.ofType(PlatformClientConnectionService.class)
.withBuilder(c -> new PlatformClientConnectionService()))
Expand Down Expand Up @@ -170,6 +175,14 @@ public void enhance(ComponentRegistry registry) {


UtilsKt.doOnSubModules(registry, (componentRegistry, module) -> {
// Only event processor modules expose a processorName; the doOnSubModules walker
// visits every sub-module (event-sourced entities, command/query modules, ...), so
// skipping non-processor modules here keeps AxoniqPlatformEventHandlingComponent
// from being constructed with a null processor name.
if (!(module instanceof PooledStreamingEventProcessorModule)
&& !(module instanceof SubscribingEventProcessorModule)) {
return null;
}
componentRegistry
.registerDecorator(DecoratorDefinition.forType(EventHandlingComponent.class)
.with((cc, name, delegate) ->
Expand All @@ -181,7 +194,7 @@ public void enhance(ComponentRegistry registry) {
.order(0));

return null;
});
}, true);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package io.axoniq.platform.framework.client

import io.axoniq.platform.framework.api.AxonServerEventStoreMessageSourceInformation
import io.axoniq.platform.framework.api.CommandBusInformation
import io.axoniq.platform.framework.api.DomainEventAccessMode
import io.axoniq.platform.framework.api.CommonProcessorInformation
import io.axoniq.platform.framework.api.EventProcessorInformation
import io.axoniq.platform.framework.api.EventStoreInformation
Expand Down Expand Up @@ -61,6 +62,7 @@ import io.axoniq.framework.messaging.queryhandling.distributed.QueryBusConnector
import org.axonframework.messaging.queryhandling.interception.InterceptingQueryBus
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAmount
import kotlin.jvm.optionals.getOrNull

class SetupPayloadCreator(
private val configuration: Configuration,
Expand All @@ -81,7 +83,9 @@ class SetupPayloadCreator(
heartbeat = true,
threadDump = true,
clientStatusUpdates = true,
licenseEntitlement = hasEntitlementManager()
licenseEntitlement = hasEntitlementManager(),
modelInspection = hasStateManager(),
domainEventsInsights = resolveDomainEventAccessMode(),
)
)
}
Expand Down Expand Up @@ -346,6 +350,18 @@ class SetupPayloadCreator(
}


/**
* Checks whether a StateManager has been registered, indicating AF5 model inspection support.
*/
private fun hasStateManager(): Boolean {
try {
val stateManagerClass = Class.forName("org.axonframework.modelling.StateManager")
return configuration.hasComponent(stateManagerClass)
} catch (_: ClassNotFoundException) {
return false
}
}

/**
* Checks whether the PlatformLicenseSource have been configured, in which case we want updates of licenses from Platform.
*/
Expand All @@ -357,6 +373,16 @@ class SetupPayloadCreator(
return false
}
}

/**
* Resolves the privacy gate the operator configured for the model-inspection (and AF4
* aggregate) routes. Falls back to [DomainEventAccessMode.NONE] when no mode was
* registered — matches the AF4 console-framework-client contract: payloads and state are
* redacted unless the operator explicitly opts in.
*/
private fun resolveDomainEventAccessMode(): DomainEventAccessMode =
configuration.getOptionalComponent(DomainEventAccessMode::class.java).getOrNull()
?: DomainEventAccessMode.NONE
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2022-2026. AxonIQ B.V.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.axoniq.platform.framework.eventsourcing

import org.axonframework.messaging.core.Context
import org.axonframework.messaging.core.unitofwork.ProcessingContext
import org.axonframework.messaging.eventhandling.EventMessage
import org.axonframework.modelling.EntityEvolver
import java.util.function.BiConsumer

/**
* Wraps the underlying [EntityEvolver] of an [org.axonframework.eventsourcing.EventSourcingRepository]
* so that inspection-time replay can fire BEFORE/AFTER hooks per event without reimplementing
* AF5's dispatch.
*
* The hooks are no-ops unless the matching [Context.ResourceKey] resources are present on the
* [ProcessingContext], so command handling and normal event sourcing go through unchanged.
*
* Constructed by [AxoniqPlatformModelInspectionEnhancer], which detects the inner
* [org.axonframework.eventsourcing.EventSourcingRepository] in the decorator chain and reconstructs
* it with this wrapper substituted for its `entityEvolver` argument.
*/
class AxoniqPlatformEntityEvolver<E : Any>(
private val delegate: EntityEvolver<E>,
) : EntityEvolver<E> {

companion object {
/** Called before [delegate.evolve]. Receives the event and the pre-evolve entity state. */
val BEFORE_CONSUMER: Context.ResourceKey<BiConsumer<EventMessage, Any?>> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.BEFORE_CONSUMER")

/** Called after [delegate.evolve]. Receives the event and the post-evolve entity state. */
val AFTER_CONSUMER: Context.ResourceKey<BiConsumer<EventMessage, Any?>> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.AFTER_CONSUMER")

/**
* Zero-based event index — when present, [evolve] returns the current entity unchanged
* once it has applied this many events. Lets inspection reconstruct state up to a given
* sequence without doing the bookkeeping outside the framework.
*/
val MAX_INDEX: Context.ResourceKey<Long> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.MAX_INDEX")

/** Internal counter advanced by [evolve] when [MAX_INDEX] is set. */
val INDEX_COUNTER: Context.ResourceKey<LongCounter> =
Context.ResourceKey.withLabel("AxoniqPlatformEntityEvolver.INDEX_COUNTER")
}

/** Tiny mutable holder so we can advance the index without re-putting a Long resource each call. */
class LongCounter(var value: Long = 0)

override fun evolve(entity: E, event: EventMessage, context: ProcessingContext): E {
val maxIndex = context.getResource(MAX_INDEX)
if (maxIndex != null) {
val counter = context.computeResourceIfAbsent(INDEX_COUNTER) { LongCounter() }
if (counter.value > maxIndex) {
return entity
}
counter.value++
}
context.getResource(BEFORE_CONSUMER)?.accept(event, entity)
val result = delegate.evolve(entity, event, context)
context.getResource(AFTER_CONSUMER)?.accept(event, result)
return result
}
}
Loading
Loading