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
11 changes: 9 additions & 2 deletions .github/workflows/phpunit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
jobs:
symfony-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dbal: ['^3.8', '^4.0']
name: PHPUnit (DBAL ${{ matrix.dbal }})
steps:
- name: Runs Elasticsearch
run: |
Expand All @@ -30,11 +35,13 @@ jobs:
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
key: ${{ runner.os }}-php-dbal${{ matrix.dbal }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
${{ runner.os }}-php-dbal${{ matrix.dbal }}-
- name: Pin Elasticsearch client to the server major version
run: composer require --no-update --no-interaction "elasticsearch/elasticsearch:^8.0"
- name: Pin Doctrine DBAL to the matrix major version
run: composer require --no-update --no-interaction "doctrine/dbal:${{ matrix.dbal }}"
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Create Database
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- `storage` configuration option to select the storage backend: `elasticsearch` (default), `doctrine` or `in_memory`; when a non-Elasticsearch backend is selected, no Elasticsearch services are registered
- Doctrine DBAL storage adapter in `Locastic\Loggastic\Bridge\Doctrine\Storage`: stores activity logs and current data trackers in two shared database tables with JSON columns, works with any DBAL-supported database (PostgreSQL, MySQL, SQLite, ...) and requires no Elasticsearch; each object keeps a single current data tracker row, so repeated saves (message retries, re-running the populate command) update it instead of failing
- In-memory storage adapter in `Locastic\Loggastic\Storage\InMemory` for running the full logging flow in test suites without an external service
- `doctrine/dbal` (`^3.8 || ^4.0`) added as an explicit dependency

## [2.0.0] - 2026-07-03

Major release: pluggable storage. Elasticsearch is now one storage backend
Expand Down
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,40 @@ Loggastic<br>
</h1>

Loggastic is made for tracking changes to your objects and their relations.
Built on top of the **Symfony framework**, this library makes it easy to implement activity logs and store them on **Elasticsearch** for fast logs browsing.
Built on top of the **Symfony framework**, this library makes it easy to implement activity logs and store them in **Elasticsearch** or in your **relational database** (via Doctrine DBAL).

Each tracked entity will have two indexes in the ElasticSearch:
1. `entity_name_activity_log` -> saving all CRUD actions made on an object. And additionally saving before and after values for Edit actions.
2. `entity_name_current_data_tracker` -> saving the latest object values used for comparing the changes made on Edit actions. This enables us to only store before and after values for modified fields in the `activity_log` index
Two kinds of records are stored for each tracked entity:
1. **Activity logs** -> saving all CRUD actions made on an object. And additionally saving before and after values for Edit actions.
2. **Current data trackers** -> saving the latest object values used for comparing the changes made on Edit actions. This enables us to only store before and after values for modified fields in the activity logs.

System requirements
-------------------

- PHP 8.2+ with Symfony 6.4, 7.x or 8.x (Symfony 8 requires PHP 8.4)
- Doctrine ORM 3.4+ with DoctrineBundle 2.8+ or 3.x
- Elasticsearch 8 or 9
- A storage backend: Elasticsearch 8 or 9 (default), or any relational database supported by Doctrine DBAL

Installation
------------

`composer require locastic/loggastic`

Requirements: Elasticsearch 8 or 9, and a PSR-18 HTTP client implementation for the Elasticsearch client (for example `composer require symfony/http-client nyholm/psr7`).
Choose your storage
-------------------

Activity logs are stored in **Elasticsearch** by default, which is a great fit for fast log browsing on high-traffic data. Requirements: Elasticsearch 8 or 9, and a PSR-18 HTTP client implementation for the Elasticsearch client (for example `composer require symfony/http-client nyholm/psr7`). Each loggable entity gets two indexes: `entity_name_activity_log` and `entity_name_current_data_tracker`.

If you don't want to run an Elasticsearch cluster, store the logs in your existing **relational database** instead (PostgreSQL, MySQL, SQLite, or anything else supported by Doctrine DBAL):

```yaml
# config/packages/loggastic.yaml
locastic_loggastic:
storage: doctrine
```

The Doctrine storage uses your default DBAL connection and keeps all activity logs in two shared tables (`loggastic_activity_log` and `loggastic_current_data_tracker`), with JSON columns for changes data. Timestamps are stored in UTC. No Elasticsearch dependency or configuration is needed.

For test suites there is also an `in_memory` storage that keeps logs in the PHP process, so the full logging flow runs without any external service.

Making your entity loggable
---------------------------
Expand Down Expand Up @@ -128,7 +143,9 @@ class Tag

Note: You can also use **annotations, xml and yaml**! Examples coming soon.

### 3. Run commands for creating indexes in ElasticSearch
### 3. Initialize the storage

Create the Elasticsearch indexes or database tables for your loggable classes:

bin/console locastic:activity-logs:create-loggable-indexes

Expand Down Expand Up @@ -201,7 +218,7 @@ Each time some change happens in the database for loggable entities, the activit
Now that you have the basic setup, you can add some additional options and customize the library to your needs.

### Custom storage backends
Activity logs are stored in Elasticsearch by default, but the core services only talk to three storage interfaces:
The built-in backends are selected with the `storage` config option (`elasticsearch`, `doctrine` or `in_memory`). The core services only talk to three storage interfaces:

```php
Locastic\Loggastic\Storage\ActivityLogStorageInterface # writes and reads activity logs
Expand All @@ -226,6 +243,9 @@ Default configuration:
```yaml
# config/packages/loggastic.yaml
locastic_loggastic:
# storage backend for activity logs: 'elasticsearch', 'doctrine' or 'in_memory'
storage: elasticsearch

# directory paths containing loggable classes or xml/yaml files
loggable_paths:
- '%kernel.project_dir%/Resources/config/loggastic'
Expand All @@ -239,7 +259,7 @@ locastic_loggastic:
# if set to `false` default numeric array keys will be used
identifier_extractor: true

# ElasticSearch config
# ElasticSearch config (only used when storage is 'elasticsearch')
elastic_host: 'localhost:9200'
elastic_user: null # basic auth username, for secured clusters
elastic_password: null # basic auth password, for secured clusters
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "locastic/loggastic",
"description": "ElasticSearch activity logs for Symfony",
"description": "Activity logs for Symfony, stored in Elasticsearch or your relational database",
"license": "MIT",
"type": "symfony-bundle",
"authors": [
Expand All @@ -11,6 +11,7 @@
],
"require": {
"php": ">=8.2",
"doctrine/dbal": "^3.8 || ^4.0",
"doctrine/doctrine-bundle": "^2.8 || ^3.0",
"doctrine/orm": "^3.4",
"elasticsearch/elasticsearch": "^8.0 || ^9.0",
Expand Down
5 changes: 5 additions & 0 deletions config/definition.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@

$definition->rootNode()
->children()
->enumNode('storage')
->info('Storage backend for activity logs and current data trackers.')
->values(['elasticsearch', 'doctrine', 'in_memory'])
->defaultValue('elasticsearch')
->end()
->booleanNode('default_doctrine_subscriber')
->defaultTrue()
->end()
Expand Down
18 changes: 18 additions & 0 deletions config/storage_doctrine.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
services:
Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineActivityLogStorage:
autowire: true

Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineCurrentDataTrackerStorage:
autowire: true

Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineStorageInitializer:
autowire: true

Locastic\Loggastic\Storage\ActivityLogStorageInterface:
alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineActivityLogStorage'

Locastic\Loggastic\Storage\CurrentDataTrackerStorageInterface:
alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineCurrentDataTrackerStorage'

Locastic\Loggastic\Storage\StorageInitializerInterface:
alias: 'Locastic\Loggastic\Bridge\Doctrine\Storage\DoctrineStorageInitializer'
16 changes: 16 additions & 0 deletions config/storage_in_memory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
services:
Locastic\Loggastic\Storage\InMemory\InMemoryActivityLogStorage: ~

Locastic\Loggastic\Storage\InMemory\InMemoryCurrentDataTrackerStorage: ~

Locastic\Loggastic\Storage\InMemory\InMemoryStorageInitializer:
autowire: true

Locastic\Loggastic\Storage\ActivityLogStorageInterface:
alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryActivityLogStorage'

Locastic\Loggastic\Storage\CurrentDataTrackerStorageInterface:
alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryCurrentDataTrackerStorage'

Locastic\Loggastic\Storage\StorageInitializerInterface:
alias: 'Locastic\Loggastic\Storage\InMemory\InMemoryStorageInitializer'
112 changes: 112 additions & 0 deletions src/Bridge/Doctrine/Storage/DoctrineActivityLogStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace Locastic\Loggastic\Bridge\Doctrine\Storage;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Locastic\Loggastic\Model\Input\ActivityLogInputInterface;
use Locastic\Loggastic\Model\Output\ActivityLog;
use Locastic\Loggastic\Model\Output\ActivityLogInterface;
use Locastic\Loggastic\Storage\ActivityLogStorageInterface;

/**
* Stores activity logs in a relational database using Doctrine DBAL.
* All loggable classes share a single table, discriminated by the object_class column.
* Timestamps are normalized to UTC before writing.
*/
final class DoctrineActivityLogStorage implements ActivityLogStorageInterface
{
public const DEFAULT_TABLE = 'loggastic_activity_log';

private const SORTABLE_FIELDS = ['loggedAt' => 'logged_at', 'action' => 'action', 'objectId' => 'object_id'];

public function __construct(
private readonly Connection $connection,
private readonly string $table = self::DEFAULT_TABLE,
) {
}

public function save(ActivityLogInputInterface $activityLog, string $className): void
{
$this->connection->insert($this->table, [
'object_id' => $activityLog->getObjectId(),
'object_class' => $className,
'action' => $activityLog->getAction(),
'logged_at' => \DateTimeImmutable::createFromMutable($activityLog->getLoggedAt())->setTimezone(new \DateTimeZone('UTC')),
'data_changes' => $activityLog->getDataChanges(),
'request_url' => $activityLog->getRequestUrl(),
'user_data' => null !== $activityLog->getUser() ? json_encode($activityLog->getUser(), JSON_THROW_ON_ERROR) : null,
], [
'logged_at' => Types::DATETIME_IMMUTABLE,
]);
}

/**
* @param array<string, string> $sort
*
* @return array<int, ActivityLogInterface>
*/
public function findByClass(string $className, array $sort = [], int $limit = 20, int $offset = 0): array
{
return $this->query($className, null, $sort, $limit, $offset);
}

/**
* @param array<string, string> $sort
*
* @return array<int, ActivityLogInterface>
*/
public function findByClassAndObjectId(string $className, mixed $objectId, array $sort = [], int $limit = 20, int $offset = 0): array
{
return $this->query($className, $objectId, $sort, $limit, $offset);
}

/**
* @param array<string, string> $sort
*
* @return array<int, ActivityLogInterface>
*/
private function query(string $className, mixed $objectId, array $sort, int $limit, int $offset): array
{
$queryBuilder = $this->connection->createQueryBuilder()
->select('*')
->from($this->table)
->where('object_class = :objectClass')
->setParameter('objectClass', $className)
->setMaxResults($limit)
->setFirstResult($offset);

if (null !== $objectId) {
$queryBuilder->andWhere('object_id = :objectId')->setParameter('objectId', (string) $objectId);
}

foreach ($sort as $field => $direction) {
if (isset(self::SORTABLE_FIELDS[$field])) {
$queryBuilder->addOrderBy(self::SORTABLE_FIELDS[$field], 'desc' === strtolower((string) $direction) ? 'DESC' : 'ASC');
}
}

// deterministic order and tiebreak, insertion order matches ES behavior
$queryBuilder->addOrderBy('id', 'ASC');

return array_map($this->hydrate(...), $queryBuilder->executeQuery()->fetchAllAssociative());
}

/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): ActivityLog
{
$activityLog = new ActivityLog();
$activityLog->setId((string) $row['id']);
$activityLog->setAction($row['action']);
$activityLog->setLoggedAt(new \DateTime($row['logged_at'], new \DateTimeZone('UTC')));
$activityLog->setObjectId($row['object_id']);
$activityLog->setObjectClass($row['object_class']);
$activityLog->setDataChanges($row['data_changes']);
$activityLog->setUser(null !== $row['user_data'] ? json_decode($row['user_data'], true, 512, JSON_THROW_ON_ERROR) : null);
$activityLog->setRequestUrl($row['request_url']);

return $activityLog;
}
}
Loading
Loading