davsclaus commented on code in PR #25507:
URL: https://github.com/apache/camel/pull/25507#discussion_r3842428037
##########
components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java:
##########
@@ -24,7 +24,12 @@ public final class GenAiObservabilityProperties {
/**
* Global property to enable or disable GenAI observability across all AI
components. Default is {@code true}.
*/
- public static final String ENABLED = "camel.ai.observability.enabled";
+ public static final String ENABLED = "camel.aiObservability.enabled";
+
+ /**
+ * Dash-style alias for {@link #ENABLED}, accepted in
application.properties and resolved by Camel Main.
+ */
+ public static final String ENABLED_DASH = "camel.ai-observability.enabled";
Review Comment:
This dash-style alias is read directly via
`PropertiesComponent.resolveProperty()` (see `GenAiObservability.isEnabled()`),
bypassing Camel Main's `setPropertiesOnTarget`/`validateOptionAndValue` path
that the camelCase `ENABLED` key goes through. That means a typo in this
spelling is silently ignored instead of failing fast, and there's no precedent
for a dash-case alias elsewhere in `camel-main`'s config groups. Please drop
this alias or add explicit unknown-option validation for it.
##########
components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java:
##########
@@ -52,6 +52,9 @@ public static boolean isEnabled(CamelContext camelContext) {
}
Optional<String> property
=
camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED);
+ if (property.isEmpty()) {
+ property =
camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED_DASH);
Review Comment:
Same concern as the `ENABLED_DASH` definition: this fallback lookup has no
typo/unknown-key protection since it's a raw property resolution outside Main's
configurer-validation path.
##########
components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java:
##########
@@ -16,81 +16,301 @@
*/
package org.apache.camel.component.ai.observability;
-import dev.langchain4j.model.ModelProvider;
-import dev.langchain4j.model.chat.ChatModel;
-import dev.langchain4j.model.chat.response.ChatResponse;
-import dev.langchain4j.model.embedding.EmbeddingModel;
+import java.util.HashSet;
+import java.util.Set;
/**
- * Resolves GenAI provider and model metadata from LangChain4j model beans.
+ * Resolves GenAI provider and model metadata from LangChain4j and Spring AI
model beans.
+ * <p/>
+ * LangChain4j types are resolved reflectively so callers such as {@code
camel-spring-ai-chat} do not require
+ * {@code langchain4j-core} on the classpath.
*/
public final class GenAiModelResolver {
private static final String UNKNOWN = "unknown";
+ private static final int MAX_MODEL_UNWRAP_DEPTH = 4;
+
+ private static final String LANGCHAIN4J_CHAT_MODEL =
"dev.langchain4j.model.chat.ChatModel";
+ private static final String LANGCHAIN4J_EMBEDDING_MODEL =
"dev.langchain4j.model.embedding.EmbeddingModel";
+ private static final String LANGCHAIN4J_CHAT_RESPONSE =
"dev.langchain4j.model.chat.response.ChatResponse";
+
+ private static volatile Boolean langChain4jPresent;
private GenAiModelResolver() {
}
public static String resolveSystem(Object model) {
- if (model == null) {
+ return resolveSystem(model, new HashSet<>(), 0);
+ }
+
+ public static String resolveModelName(Object model) {
+ return resolveModelName(model, new HashSet<>(), 0);
+ }
+
+ /**
+ * Resolves the response model from a LangChain4j or Spring AI chat
response, falling back when absent.
+ */
+ public static String resolveResponseModelName(Object chatResponse, String
fallback) {
+ if (chatResponse == null) {
+ return fallback;
+ }
+ if (isLangChain4jPresent() && isInstanceOf(chatResponse,
LANGCHAIN4J_CHAT_RESPONSE)) {
+ String modelName = invokeToString(chatResponse, "modelName");
+ if (modelName != null && !modelName.isBlank()) {
+ return modelName;
+ }
+ }
+ return resolveSpringAiResponseModelName(chatResponse, fallback);
+ }
+
+ /**
+ * Resolves the response model from a Spring AI {@code ChatResponse},
falling back when absent.
+ */
+ public static String resolveSpringAiResponseModelName(Object chatResponse,
String fallback) {
+ if (chatResponse == null) {
+ return fallback;
+ }
+ try {
+ Object metadata =
chatResponse.getClass().getMethod("getMetadata").invoke(chatResponse);
+ if (metadata != null) {
+ Object model =
metadata.getClass().getMethod("getModel").invoke(metadata);
+ if (model != null && !model.toString().isBlank()) {
+ return model.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return fallback;
+ }
+
+ private static String resolveSystem(Object model, Set<Integer> visited,
int depth) {
+ if (model == null || depth > MAX_MODEL_UNWRAP_DEPTH) {
+ return UNKNOWN;
+ }
+ if (!markVisited(model, visited)) {
return UNKNOWN;
}
- if (model instanceof ChatModel chatModel) {
- return mapProvider(chatModel.provider());
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_CHAT_MODEL)) {
+ return mapLangChain4jProvider(invokeToString(model, "provider"));
}
- if (model instanceof EmbeddingModel embeddingModel) {
- return mapProvider(embeddingModel.provider());
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_EMBEDDING_MODEL)) {
+ return mapLangChain4jProvider(invokeToString(model, "provider"));
+ }
+ if (isSpringAiType(model)) {
+ String fromUnderlyingModel =
resolveSystemFromSpringAiUnderlyingModel(model, visited, depth + 1);
+ if (!UNKNOWN.equals(fromUnderlyingModel)) {
+ return fromUnderlyingModel;
+ }
+ String fromPackage =
resolveSystemFromSpringAiPackage(resolveSpringAiPackageName(model));
+ if (!UNKNOWN.equals(fromPackage)) {
+ return fromPackage;
+ }
}
return resolveSystemFromPackage(model.getClass().getPackageName());
}
- public static String resolveModelName(Object model) {
- if (model == null) {
+ private static String resolveModelName(Object model, Set<Integer> visited,
int depth) {
+ if (model == null || depth > MAX_MODEL_UNWRAP_DEPTH) {
return UNKNOWN;
}
- if (model instanceof ChatModel chatModel) {
- String modelName =
chatModel.defaultRequestParameters().modelName();
+ if (!markVisited(model, visited)) {
+ return UNKNOWN;
+ }
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_CHAT_MODEL)) {
+ String modelName = resolveLangChain4jChatModelName(model);
if (modelName != null && !modelName.isBlank()) {
return modelName;
}
}
- if (model instanceof EmbeddingModel embeddingModel) {
- String modelName = embeddingModel.modelName();
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_EMBEDDING_MODEL)) {
+ String modelName = invokeToString(model, "modelName");
if (modelName != null && !modelName.isBlank()) {
return modelName;
}
}
+ if (isSpringAiType(model)) {
+ return resolveSpringAiModelName(model, visited, depth + 1);
+ }
return UNKNOWN;
}
- /**
- * Resolves the response model from a LangChain4j {@link ChatResponse},
falling back when absent.
- */
- public static String resolveResponseModelName(ChatResponse chatResponse,
String fallback) {
- if (chatResponse == null) {
- return fallback;
+ private static String resolveLangChain4jChatModelName(Object model) {
+ try {
+ Object parameters = invokeNoArg(model, "defaultRequestParameters");
+ if (parameters != null) {
+ Object modelName = invokeNoArgOptional(parameters,
"modelName");
+ if (modelName != null && !modelName.toString().isBlank()) {
+ return modelName.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static String resolveSystemFromSpringAiUnderlyingModel(Object
model, Set<Integer> visited, int depth) {
+ try {
+ Object chatModel = invokeNoArgOptional(model, "getChatModel");
+ if (chatModel != null && chatModel != model) {
+ return resolveSystem(chatModel, visited, depth);
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return UNKNOWN;
+ }
+
+ private static String resolveSpringAiModelName(Object model, Set<Integer>
visited, int depth) {
+ try {
+ Object chatModel = invokeNoArgOptional(model, "getChatModel");
+ if (chatModel != null && chatModel != model) {
+ String resolved = resolveModelName(chatModel, visited, depth);
+ if (!UNKNOWN.equals(resolved)) {
+ return resolved;
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ try {
+ Object options = invokeNoArgOptional(model, "getDefaultOptions");
+ if (options == null) {
+ options = invokeNoArgOptional(model, "getOptions");
+ }
+ if (options == null) {
+ options = invokeNoArgOptional(model, "getDefaultChatOptions");
+ }
+ if (options != null) {
+ Object modelName = invokeNoArgOptional(options, "getModel");
+ if (modelName != null && !modelName.toString().isBlank()) {
+ return modelName.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return UNKNOWN;
+ }
+
+ private static boolean isSpringAiType(Object model) {
+ if (resolveSpringAiPackageName(model) != null) {
+ return true;
+ }
+ for (Class<?> iface : model.getClass().getInterfaces()) {
+ if (iface.getName().startsWith("org.springframework.ai.")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String resolveSpringAiPackageName(Object model) {
+ String packageName = model.getClass().getPackageName();
+ if (packageName.startsWith("org.springframework.ai.")) {
+ return packageName;
+ }
+ return null;
+ }
+
+ private static boolean markVisited(Object model, Set<Integer> visited) {
+ return visited.add(System.identityHashCode(model));
+ }
+
+ private static boolean isLangChain4jPresent() {
Review Comment:
`Class.forName` is called here using the resolver's own classloader rather
than Camel's `ClassResolver` SPI (`camelContext.getClassResolver()`), which is
the convention used elsewhere in the codebase for classloading that needs to
work under OSGi/modular runtimes. The result is also cached in a single
`volatile Boolean`, which works for this one presence check, but the pattern
below (`isInstanceOf`/`invokeNoArg`) repeats uncached
`Class.forName`/reflective method lookups on every call.
##########
components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java:
##########
@@ -16,81 +16,301 @@
*/
package org.apache.camel.component.ai.observability;
-import dev.langchain4j.model.ModelProvider;
-import dev.langchain4j.model.chat.ChatModel;
-import dev.langchain4j.model.chat.response.ChatResponse;
-import dev.langchain4j.model.embedding.EmbeddingModel;
+import java.util.HashSet;
+import java.util.Set;
/**
- * Resolves GenAI provider and model metadata from LangChain4j model beans.
+ * Resolves GenAI provider and model metadata from LangChain4j and Spring AI
model beans.
+ * <p/>
+ * LangChain4j types are resolved reflectively so callers such as {@code
camel-spring-ai-chat} do not require
+ * {@code langchain4j-core} on the classpath.
*/
public final class GenAiModelResolver {
private static final String UNKNOWN = "unknown";
+ private static final int MAX_MODEL_UNWRAP_DEPTH = 4;
+
+ private static final String LANGCHAIN4J_CHAT_MODEL =
"dev.langchain4j.model.chat.ChatModel";
+ private static final String LANGCHAIN4J_EMBEDDING_MODEL =
"dev.langchain4j.model.embedding.EmbeddingModel";
+ private static final String LANGCHAIN4J_CHAT_RESPONSE =
"dev.langchain4j.model.chat.response.ChatResponse";
+
+ private static volatile Boolean langChain4jPresent;
private GenAiModelResolver() {
}
public static String resolveSystem(Object model) {
- if (model == null) {
+ return resolveSystem(model, new HashSet<>(), 0);
+ }
+
+ public static String resolveModelName(Object model) {
+ return resolveModelName(model, new HashSet<>(), 0);
+ }
+
+ /**
+ * Resolves the response model from a LangChain4j or Spring AI chat
response, falling back when absent.
+ */
+ public static String resolveResponseModelName(Object chatResponse, String
fallback) {
+ if (chatResponse == null) {
+ return fallback;
+ }
+ if (isLangChain4jPresent() && isInstanceOf(chatResponse,
LANGCHAIN4J_CHAT_RESPONSE)) {
+ String modelName = invokeToString(chatResponse, "modelName");
+ if (modelName != null && !modelName.isBlank()) {
+ return modelName;
+ }
+ }
+ return resolveSpringAiResponseModelName(chatResponse, fallback);
+ }
+
+ /**
+ * Resolves the response model from a Spring AI {@code ChatResponse},
falling back when absent.
+ */
+ public static String resolveSpringAiResponseModelName(Object chatResponse,
String fallback) {
+ if (chatResponse == null) {
+ return fallback;
+ }
+ try {
+ Object metadata =
chatResponse.getClass().getMethod("getMetadata").invoke(chatResponse);
+ if (metadata != null) {
+ Object model =
metadata.getClass().getMethod("getModel").invoke(metadata);
+ if (model != null && !model.toString().isBlank()) {
+ return model.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return fallback;
+ }
+
+ private static String resolveSystem(Object model, Set<Integer> visited,
int depth) {
+ if (model == null || depth > MAX_MODEL_UNWRAP_DEPTH) {
+ return UNKNOWN;
+ }
+ if (!markVisited(model, visited)) {
return UNKNOWN;
}
- if (model instanceof ChatModel chatModel) {
- return mapProvider(chatModel.provider());
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_CHAT_MODEL)) {
+ return mapLangChain4jProvider(invokeToString(model, "provider"));
}
- if (model instanceof EmbeddingModel embeddingModel) {
- return mapProvider(embeddingModel.provider());
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_EMBEDDING_MODEL)) {
+ return mapLangChain4jProvider(invokeToString(model, "provider"));
+ }
+ if (isSpringAiType(model)) {
+ String fromUnderlyingModel =
resolveSystemFromSpringAiUnderlyingModel(model, visited, depth + 1);
+ if (!UNKNOWN.equals(fromUnderlyingModel)) {
+ return fromUnderlyingModel;
+ }
+ String fromPackage =
resolveSystemFromSpringAiPackage(resolveSpringAiPackageName(model));
+ if (!UNKNOWN.equals(fromPackage)) {
+ return fromPackage;
+ }
}
return resolveSystemFromPackage(model.getClass().getPackageName());
}
- public static String resolveModelName(Object model) {
- if (model == null) {
+ private static String resolveModelName(Object model, Set<Integer> visited,
int depth) {
+ if (model == null || depth > MAX_MODEL_UNWRAP_DEPTH) {
return UNKNOWN;
}
- if (model instanceof ChatModel chatModel) {
- String modelName =
chatModel.defaultRequestParameters().modelName();
+ if (!markVisited(model, visited)) {
+ return UNKNOWN;
+ }
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_CHAT_MODEL)) {
+ String modelName = resolveLangChain4jChatModelName(model);
if (modelName != null && !modelName.isBlank()) {
return modelName;
}
}
- if (model instanceof EmbeddingModel embeddingModel) {
- String modelName = embeddingModel.modelName();
+ if (isLangChain4jPresent() && isInstanceOf(model,
LANGCHAIN4J_EMBEDDING_MODEL)) {
+ String modelName = invokeToString(model, "modelName");
if (modelName != null && !modelName.isBlank()) {
return modelName;
}
}
+ if (isSpringAiType(model)) {
+ return resolveSpringAiModelName(model, visited, depth + 1);
+ }
return UNKNOWN;
}
- /**
- * Resolves the response model from a LangChain4j {@link ChatResponse},
falling back when absent.
- */
- public static String resolveResponseModelName(ChatResponse chatResponse,
String fallback) {
- if (chatResponse == null) {
- return fallback;
+ private static String resolveLangChain4jChatModelName(Object model) {
+ try {
+ Object parameters = invokeNoArg(model, "defaultRequestParameters");
+ if (parameters != null) {
+ Object modelName = invokeNoArgOptional(parameters,
"modelName");
+ if (modelName != null && !modelName.toString().isBlank()) {
+ return modelName.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return null;
+ }
+
+ private static String resolveSystemFromSpringAiUnderlyingModel(Object
model, Set<Integer> visited, int depth) {
+ try {
+ Object chatModel = invokeNoArgOptional(model, "getChatModel");
+ if (chatModel != null && chatModel != model) {
+ return resolveSystem(chatModel, visited, depth);
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return UNKNOWN;
+ }
+
+ private static String resolveSpringAiModelName(Object model, Set<Integer>
visited, int depth) {
+ try {
+ Object chatModel = invokeNoArgOptional(model, "getChatModel");
+ if (chatModel != null && chatModel != model) {
+ String resolved = resolveModelName(chatModel, visited, depth);
+ if (!UNKNOWN.equals(resolved)) {
+ return resolved;
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ try {
+ Object options = invokeNoArgOptional(model, "getDefaultOptions");
+ if (options == null) {
+ options = invokeNoArgOptional(model, "getOptions");
+ }
+ if (options == null) {
+ options = invokeNoArgOptional(model, "getDefaultChatOptions");
+ }
+ if (options != null) {
+ Object modelName = invokeNoArgOptional(options, "getModel");
+ if (modelName != null && !modelName.toString().isBlank()) {
+ return modelName.toString();
+ }
+ }
+ } catch (ReflectiveOperationException e) {
+ // ignore
+ }
+ return UNKNOWN;
+ }
+
+ private static boolean isSpringAiType(Object model) {
+ if (resolveSpringAiPackageName(model) != null) {
+ return true;
+ }
+ for (Class<?> iface : model.getClass().getInterfaces()) {
+ if (iface.getName().startsWith("org.springframework.ai.")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String resolveSpringAiPackageName(Object model) {
+ String packageName = model.getClass().getPackageName();
+ if (packageName.startsWith("org.springframework.ai.")) {
+ return packageName;
+ }
+ return null;
+ }
+
+ private static boolean markVisited(Object model, Set<Integer> visited) {
+ return visited.add(System.identityHashCode(model));
+ }
+
+ private static boolean isLangChain4jPresent() {
+ if (langChain4jPresent == null) {
+ try {
+ Class.forName(LANGCHAIN4J_CHAT_MODEL, false,
GenAiModelResolver.class.getClassLoader());
+ langChain4jPresent = true;
+ } catch (ClassNotFoundException e) {
+ langChain4jPresent = false;
+ }
}
- String modelName = chatResponse.modelName();
- return modelName != null && !modelName.isBlank() ? modelName :
fallback;
+ return langChain4jPresent;
}
- private static String mapProvider(ModelProvider provider) {
- if (provider == null) {
+ private static boolean isInstanceOf(Object model, String className) {
+ try {
+ Class<?> type = Class.forName(className, false,
model.getClass().getClassLoader());
+ return type.isInstance(model);
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+
+ private static String invokeToString(Object target, String methodName) {
+ try {
+ Object value = invokeNoArg(target, methodName);
+ return value != null ? value.toString() : null;
+ } catch (ReflectiveOperationException e) {
+ return null;
+ }
+ }
+
+ private static Object invokeNoArgOptional(Object target, String
methodName) throws ReflectiveOperationException {
+ try {
+ return invokeNoArg(target, methodName);
+ } catch (NoSuchMethodException e) {
+ return null;
+ }
+ }
+
+ private static Object invokeNoArg(Object target, String methodName) throws
ReflectiveOperationException {
Review Comment:
`getClass().getMethod(methodName).invoke(target)` is looked up fresh on
every call with no caching of the resolved `Method`. Since this runs on every
chat/embedding call (a hot path for high-throughput AI routes), please cache
resolved `Method` objects (e.g., keyed by `Class<?>` + method name) rather than
re-resolving via reflection each time.
##########
components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java:
##########
@@ -1202,11 +1225,81 @@ private void
executeRequest(ChatClient.ChatClientRequestSpec request, Exchange e
} else if (converter != null) {
processStructuredOutputRequest(request, exchange, converter);
} else {
- ChatResponse response = request.call().chatResponse();
+ ChatResponse response = callWithObservability(request, exchange);
populateResponse(response, exchange);
}
}
+ private ChatResponse
callWithObservability(ChatClient.ChatClientRequestSpec request, Exchange
exchange) {
+ GenAiObservationContext observationContext = buildObservationContext();
+ GenAiObservation observation = GenAiObservability.start(exchange,
observationContext);
+ try {
+ ChatResponse response = request.call().chatResponse();
+ recordObservationSuccess(observation, response,
observationContext.requestModel());
+ return response;
+ } catch (RuntimeException e) {
+ observation.recordError(e);
+ throw e;
+ } finally {
+ observation.close();
+ }
+ }
+
+ private void recordObservationSuccess(GenAiObservation observation,
ChatResponse response, String requestModel) {
+ Integer inputTokens = null;
+ Integer outputTokens = null;
+ String finishReason = null;
+ String responseModel = requestModel;
+ if (response.getMetadata() != null) {
+ if (response.getMetadata().getUsage() != null) {
+ var usage = response.getMetadata().getUsage();
+ inputTokens = usage.getPromptTokens();
+ outputTokens = usage.getCompletionTokens();
+ }
+ if (response.getMetadata().getModel() != null) {
+ responseModel = response.getMetadata().getModel();
+ }
+ }
+ if (response.getResult() != null && response.getResult().getMetadata()
!= null) {
+ finishReason =
response.getResult().getMetadata().getFinishReason();
+ }
+ observation.recordSuccess(GenAiUsage.of(inputTokens, outputTokens,
finishReason, responseModel));
+ }
+
+ private GenAiObservationContext buildObservationContext() {
+ Object modelSource = resolveObservabilityModelSource();
+ String requestModel = GenAiModelResolver.resolveModelName(modelSource);
+ return GenAiObservationContext.builder()
+ .operationName(GenAiOperationName.CHAT)
+ .system(GenAiModelResolver.resolveSystem(modelSource))
+ .requestModel(requestModel)
+ .componentScheme("spring-ai-chat")
+ .build();
+ }
+
+ private Object resolveObservabilityModelSource() {
+ if (observabilityChatModel != null) {
+ return observabilityChatModel;
+ }
+ ChatModel chatModel = getEndpoint().getConfiguration().getChatModel();
+ if (chatModel != null) {
+ return chatModel;
+ }
+ return chatClient;
+ }
+
+ private ChatModel extractChatModelFromClient(ChatClient client) {
Review Comment:
Non-blocking: this reaches into `ChatClient.ChatClientRequestSpec`'s private
`chatModel` field via reflection, which isn't public Spring AI API. If a future
Spring AI version renames/restructures this field, observability silently
degrades to `system=unknown` for chatClient-only configurations -- the failure
is only logged at DEBUG. Consider a one-time WARN so operators notice after a
Spring AI upgrade.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]