Skip to content
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ OTEL_TRACES_SAMPLER_PARENT=false
# OTEL_INSTRUMENTATION_VIEW=true
# OTEL_INSTRUMENTATION_LIVEWIRE=true
# OTEL_INSTRUMENTATION_CONSOLE=true

OTEL_AUDIT_ELASTICSEARCH_INDEX=logs-audit
# SWAGGER CONFIG

L5_SWAGGER_CONST_HOST=${APP_URL}
Expand Down
64 changes: 50 additions & 14 deletions app/Audit/AuditEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,72 @@
* limitations under the License.
**/

use App\Audit\AuditLogOtlpStrategy;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;

/**
* Class AuditEventListener
* @package App\Audit
*/
class AuditEventListener {
class AuditEventListener
{

public function onFlush(OnFlushEventArgs $eventArgs): void
{
$em = $eventArgs->getObjectManager();
$uow = $em->getUnitOfWork();
// Strategy selection based on environment configuration
$strategy = $this->getAuditStrategy($em);

$strategy = new AuditLogStrategy($em);

foreach ($uow->getScheduledEntityInsertions() as $entity) {
$strategy->audit($entity, null, $strategy::EVENT_ENTITY_CREATION);
if (!$strategy) {
return; // No audit strategy enabled
}

foreach ($uow->getScheduledEntityUpdates() as $entity) {
$change_set = $uow->getEntityChangeSet($entity);
$strategy->audit($entity, $change_set, $strategy::EVENT_ENTITY_UPDATE);
}
try {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$strategy->audit($entity, [], AuditLogOtlpStrategy::EVENT_ENTITY_CREATION);
}

foreach ($uow->getScheduledEntityUpdates() as $entity) {
$strategy->audit($entity, $uow->getEntityChangeSet($entity), AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE);
}

foreach ($uow->getScheduledEntityDeletions() as $entity) {
$strategy->audit($entity, [], AuditLogOtlpStrategy::EVENT_ENTITY_DELETION);
}

foreach ($uow->getScheduledEntityDeletions() as $entity) {
$strategy->audit($entity, null, $strategy::EVENT_ENTITY_DELETION);
foreach ($uow->getScheduledCollectionUpdates() as $col) {
$strategy->audit($col, [], AuditLogOtlpStrategy::EVENT_COLLECTION_UPDATE);
}
} catch (\Exception $e) {
Log::error('Audit event listener failed', [
'error' => $e->getMessage(),
'strategy_class' => get_class($strategy),
'trace' => $e->getTraceAsString(),
]);
}
}

foreach ($uow->getScheduledCollectionUpdates() as $col) {
$strategy->audit($col, null, $strategy::EVENT_COLLECTION_UPDATE);
/**
* Get the appropriate audit strategy based on environment configuration
*/
private function getAuditStrategy($em)
{
// Check if OTLP audit is enabled
if (config('opentelemetry.enabled', false)) {
try {
return App::make(AuditLogOtlpStrategy::class);
} catch (\Exception $e) {
Log::warning('Failed to create OTLP audit strategy, falling back to database', [
'error' => $e->getMessage()
]);
}
}

// Use database strategy (either as default or fallback)
return new AuditLogStrategy($em);

}
}
}
200 changes: 200 additions & 0 deletions app/Audit/AuditLogOtlpStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

namespace App\Audit;

use App\Audit\Interfaces\IAuditStrategy;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\App;
use models\summit\PresentationAction;
use models\summit\PresentationExtraQuestionAnswer;
use models\summit\SummitAttendeeBadgePrint;
use models\summit\SummitEvent;
use App\Audit\ConcreteFormatters\ChildEntityFormatters\ChildEntityFormatterFactory;
use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
use Keepsuit\LaravelOpenTelemetry\Facades\Logger;

/**
* OpenTelemetry Logs Audit Strategy
*/
class AuditLogOtlpStrategy implements IAuditStrategy
{
public const EVENT_COLLECTION_UPDATE = 'event_collection_update';
public const EVENT_ENTITY_CREATION = 'event_entity_creation';
public const EVENT_ENTITY_DELETION = 'event_entity_deletion';
public const EVENT_ENTITY_UPDATE = 'event_entity_update';

public const ACTION_CREATE = 'create';
public const ACTION_UPDATE = 'update';
public const ACTION_DELETE = 'delete';
public const ACTION_COLLECTION_UPDATE = 'collection_update';
public const ACTION_UNKNOWN = 'unknown';

public const LOG_MESSAGE_CREATED = 'audit.entity.created';
public const LOG_MESSAGE_UPDATED = 'audit.entity.updated';
public const LOG_MESSAGE_DELETED = 'audit.entity.deleted';
public const LOG_MESSAGE_COLLECTION_UPDATED = 'audit.collection.updated';
public const LOG_MESSAGE_CHANGED = 'audit.entity.changed';

private bool $enabled;
private string $elasticIndex;

public function __construct()
{
$this->enabled = config('opentelemetry.enabled', false);
$this->elasticIndex = config('opentelemetry.logs.elasticsearch_index', 'logs-audit');
}

public function audit($subject, array $change_set, string $event_type): void
{
if (!$this->enabled) {
return;
}

try {
$entity = $this->resolveAuditableEntity($subject);
if (is_null($entity)) {
return;
}

$resource_server_ctx = App::make(\models\oauth2\IResourceServerContext::class);
$user_id = $resource_server_ctx->getCurrentUserId();
$user_email = $resource_server_ctx->getCurrentUserEmail();

$formatter = null;
switch ($event_type) {
case self::EVENT_COLLECTION_UPDATE:
$child_entity = null;
if (count($subject) > 0) {
$child_entity = $subject[0];
}
if (is_null($child_entity) && count($subject->getSnapshot()) > 0) {
$child_entity = $subject->getSnapshot()[0];
}
$child_entity_formatter = $child_entity != null ? ChildEntityFormatterFactory::build($child_entity) : null;
$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
break;
case self::EVENT_ENTITY_CREATION:
$formatter = new EntityCreationAuditLogFormatter();
break;
case self::EVENT_ENTITY_DELETION:
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
$formatter = new EntityDeletionAuditLogFormatter($child_entity_formatter);
break;
case self::EVENT_ENTITY_UPDATE:
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
$formatter = new EntityUpdateAuditLogFormatter($child_entity_formatter);
break;
}

$description = null;
if ($formatter) {
$description = $formatter->format($subject, $change_set);
}

$auditData = $this->buildAuditLogData($entity, $subject, $change_set, $event_type, $user_id, $user_email);
if (!empty($description)) {
$auditData['audit.description'] = $description;
}
Logger::info($this->getLogMessage($event_type), $auditData);

} catch (\Exception $ex) {
Logger::warning('OTEL audit logging error: ' . $ex->getMessage(), [
'exception' => $ex,
'subject_class' => get_class($subject),
'event_type' => $event_type,
]);
}
}

private function resolveAuditableEntity($subject)
{
if ($subject instanceof SummitEvent) return $subject;

if ($subject instanceof PersistentCollection && $subject->getOwner() instanceof SummitEvent) {
return $subject->getOwner();
}

if ($subject instanceof PresentationAction || $subject instanceof PresentationExtraQuestionAnswer) {
return $subject->getPresentation();
}

if ($subject instanceof SummitAttendeeBadgePrint) {
return $subject->getBadge();
}

return null;
}

private function buildAuditLogData($entity, $subject, array $change_set, string $event_type, ?string $user_id, ?string $user_email): array
{
$auditData = [
'audit.action' => $this->mapEventTypeToAction($event_type),
'audit.entity' => class_basename($entity),
'audit.entity_id' => (string) (method_exists($entity, 'getId') ? $entity->getId() : 'unknown'),
'audit.entity_class' => get_class($entity),
'audit.timestamp' => now()->toISOString(),
'audit.event_type' => $event_type,
'auth.user.id' => $user_id,
'auth.user.email' => $user_email,
'elasticsearch.index' => $this->elasticIndex,
];

switch ($event_type) {
case self::EVENT_COLLECTION_UPDATE:
if ($subject instanceof PersistentCollection) {
$auditData['audit.collection_type'] = $this->getCollectionType($subject);
$auditData['audit.collection_count'] = count($subject);
$auditData['audit.collection_changes'] = $this->getCollectionChanges($subject, $change_set);
}
break;
}

return $auditData;
}

private function getCollectionType(PersistentCollection $collection): string
{
if (empty($collection) && empty($collection->getSnapshot())) {
return 'unknown';
}

$item = !empty($collection) ? $collection->first() : $collection->getSnapshot()[0];
return class_basename($item);
}

private function getCollectionChanges(PersistentCollection $collection, array $change_set): array
{
return [
'current_count' => count($collection),
'snapshot_count' => count($collection->getSnapshot()),
'is_dirty' => $collection->isDirty(),
];
}

private function mapEventTypeToAction(string $event_type): string
{
return match($event_type) {
self::EVENT_ENTITY_CREATION => self::ACTION_CREATE,
self::EVENT_ENTITY_UPDATE => self::ACTION_UPDATE,
self::EVENT_ENTITY_DELETION => self::ACTION_DELETE,
self::EVENT_COLLECTION_UPDATE => self::ACTION_COLLECTION_UPDATE,
default => self::ACTION_UNKNOWN
};
}

private function getLogMessage(string $event_type): string
{
return match($event_type) {
self::EVENT_ENTITY_CREATION => self::LOG_MESSAGE_CREATED,
self::EVENT_ENTITY_UPDATE => self::LOG_MESSAGE_UPDATED,
self::EVENT_ENTITY_DELETION => self::LOG_MESSAGE_DELETED,
self::EVENT_COLLECTION_UPDATE => self::LOG_MESSAGE_COLLECTION_UPDATED,
default => self::LOG_MESSAGE_CHANGED
};
}


}
21 changes: 21 additions & 0 deletions app/Audit/Interfaces/IAuditStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Audit\Interfaces;

/**
* Audit Strategy Interface
* Defines contract for different audit implementations (DB, OTLP, etc.)
*/
interface IAuditStrategy
{
/**
* Audit an entity change
*
* @param mixed $subject The entity or collection being audited
* @param array $change_set Array of changes (field => [old, new])
* @param string $event_type Type of audit event (create, update, delete, collection_update)
* @return void
*/
public function audit($subject, array $change_set, string $event_type): void;

}
2 changes: 2 additions & 0 deletions config/opentelemetry.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
* Context field name for trace id
*/
'trace_id_field' => 'traceid',

'elasticsearch_index' => env('OTEL_LOGS_ELASTICSEARCH_INDEX', 'logs-audit'),
],

/**
Expand Down
2 changes: 1 addition & 1 deletion docker-compose/opentelemetry/otel-collector-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ service:
logs:
receivers: [otlp]
processors: [memory_limiter, resource, attributes, batch]
exporters: [debug, file]
exporters: [debug, file, elasticsearch]

# Telemetry configuration for the collector itself
telemetry:
Expand Down
Loading