Compare commits

...
Author SHA1 Message Date
loks0n ecd8971189 feat: logs 2026-02-11 22:38:57 +00:00
26 changed files with 1237 additions and 14 deletions
+3
View File
@@ -73,6 +73,7 @@ use Appwrite\Utopia\Response\Model\Framework;
use Appwrite\Utopia\Response\Model\FrameworkAdapter;
use Appwrite\Utopia\Response\Model\Func;
use Appwrite\Utopia\Response\Model\Headers;
use Appwrite\Utopia\Response\Model\HttpLog;
use Appwrite\Utopia\Response\Model\HealthAntivirus;
use Appwrite\Utopia\Response\Model\HealthCertificate;
use Appwrite\Utopia\Response\Model\HealthQueue;
@@ -183,6 +184,7 @@ Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIS
Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME));
Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_LIST, 'deployments', Response::MODEL_DEPLOYMENT));
Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION));
Response::setModel(new BaseList('HTTP Logs List', Response::MODEL_HTTP_LOG_LIST, 'httpLogs', Response::MODEL_HTTP_LOG));
Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false));
Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, false));
Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true));
@@ -303,6 +305,7 @@ Response::setModel(new Framework());
Response::setModel(new FrameworkAdapter());
Response::setModel(new Deployment());
Response::setModel(new Execution());
Response::setModel(new HttpLog());
Response::setModel(new Project());
Response::setModel(new Webhook());
Response::setModel(new Key());
+4
View File
@@ -974,6 +974,10 @@ Http::setResource('redis', function () {
return $redis;
});
Http::setResource('logs', function (\Redis $redis) {
return new \Appwrite\Logs\Redis($redis);
}, ['redis']);
Http::setResource('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
+4
View File
@@ -266,6 +266,10 @@ Server::setResource('redis', function () {
return $redis;
});
Server::setResource('logs', function (\Redis $redis) {
return new \Appwrite\Logs\Redis($redis);
}, ['redis']);
Server::setResource('timelimit', function (\Redis $redis) {
return function (string $key, int $limit, int $time) use ($redis) {
return new TimeLimitRedis($key, $limit, $time, $redis);
+1
View File
@@ -34,6 +34,7 @@
<directory>./tests/e2e/Services/Tokens</directory>
<directory>./tests/e2e/Services/Webhooks</directory>
<directory>./tests/e2e/Services/Messaging</directory>
<directory>./tests/e2e/Services/Logs</directory>
<directory>./tests/e2e/Services/Migrations</directory>
<file>./tests/e2e/Services/Functions/FunctionsBase.php</file>
<file>./tests/e2e/Services/Functions/FunctionsCustomServerTest.php</file>
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Appwrite;
use Appwrite\Logs\Log;
use Appwrite\Logs\Resource;
interface Logs
{
public function append(Log $log): void;
public function get(string $id): ?Log;
/**
* @return Log[]
*/
public function list(
Resource $resource,
string $resourceId,
int $limit = 100,
int $offset = 0,
): array;
public function count(
Resource $resource,
string $resourceId,
): int;
public function delete(string $id): void;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Appwrite\Logs;
final readonly class Log
{
public function __construct(
// Meta
public Resource $resource,
public string $resourceId,
public float $timestamp,
public float $durationSeconds,
// Request
public Method $requestMethod,
public string $requestScheme,
public string $requestHost,
public string $requestPath,
public string $requestQuery,
public int $requestSizeBytes,
// Response
public int $responseStatusCode,
public int $responseSizeBytes,
) {
}
public function toArray(): array
{
return [
'resource' => $this->resource->value,
'resourceId' => $this->resourceId,
'timestamp' => $this->timestamp,
'durationSeconds' => $this->durationSeconds,
'requestMethod' => $this->requestMethod->value,
'requestScheme' => $this->requestScheme,
'requestHost' => $this->requestHost,
'requestPath' => $this->requestPath,
'requestQuery' => $this->requestQuery,
'requestSizeBytes' => $this->requestSizeBytes,
'responseStatusCode' => $this->responseStatusCode,
'responseSizeBytes' => $this->responseSizeBytes,
];
}
public static function fromArray(array $data): self
{
return new self(
resource: Resource::from($data['resource']),
resourceId: $data['resourceId'],
timestamp: (float) $data['timestamp'],
durationSeconds: (float) $data['durationSeconds'],
requestMethod: Method::tryFrom($data['requestMethod']) ?? Method::Other,
requestScheme: $data['requestScheme'] ?? '',
requestHost: $data['requestHost'] ?? '',
requestPath: $data['requestPath'] ?? '',
requestQuery: $data['requestQuery'] ?? '',
requestSizeBytes: (int) ($data['requestSizeBytes'] ?? 0),
responseStatusCode: (int) ($data['responseStatusCode'] ?? 0),
responseSizeBytes: (int) ($data['responseSizeBytes'] ?? 0),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Appwrite\Logs;
enum Method: string
{
case Get = 'GET';
case Post = 'POST';
case Put = 'PUT';
case Patch = 'PATCH';
case Delete = 'DELETE';
case Head = 'HEAD';
case Options = 'OPTIONS';
case Trace = 'TRACE';
case Connect = 'CONNECT';
case Other = 'OTHER';
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace Appwrite\Logs;
use Appwrite\Logs;
class Redis implements Logs
{
private const PREFIX_LOG = 'log:';
private const PREFIX_INDEX = 'logs:';
private const TTL_SECONDS = 86400; // 24 hours
public function __construct(
private \Redis $redis,
) {
}
public function append(Log $log): void
{
$id = \uniqid('', true);
$key = self::PREFIX_LOG . $id;
$indexKey = self::indexKey($log->resource, $log->resourceId);
$this->redis->hMSet($key, [
'resource' => $log->resource->value,
'resourceId' => $log->resourceId,
'timestamp' => $log->timestamp,
'durationSeconds' => $log->durationSeconds,
'requestMethod' => $log->requestMethod->value,
'requestScheme' => $log->requestScheme,
'requestHost' => $log->requestHost,
'requestPath' => $log->requestPath,
'requestQuery' => $log->requestQuery,
'requestSizeBytes' => $log->requestSizeBytes,
'responseStatusCode' => $log->responseStatusCode,
'responseSizeBytes' => $log->responseSizeBytes,
]);
$this->redis->expire($key, self::TTL_SECONDS);
$this->redis->zAdd($indexKey, $log->timestamp, $id);
$this->redis->expire($indexKey, self::TTL_SECONDS);
}
public function get(string $id): ?Log
{
$key = self::PREFIX_LOG . $id;
$data = $this->redis->hGetAll($key);
if (empty($data)) {
return null;
}
return self::toLog($data);
}
/**
* @return array<string, Log>
*/
public function list(
Resource $resource,
string $resourceId,
int $limit = 100,
int $offset = 0,
): array {
$indexKey = self::indexKey($resource, $resourceId);
$ids = $this->redis->zRevRange($indexKey, $offset, $offset + $limit - 1);
if (empty($ids)) {
return [];
}
$results = [];
foreach ($ids as $id) {
$data = $this->redis->hGetAll(self::PREFIX_LOG . $id);
if (!empty($data)) {
$results[$id] = self::toLog($data);
}
}
return $results;
}
public function count(
Resource $resource,
string $resourceId,
): int {
$indexKey = self::indexKey($resource, $resourceId);
return (int) $this->redis->zCard($indexKey);
}
public function delete(string $id): void
{
$key = self::PREFIX_LOG . $id;
$data = $this->redis->hGetAll($key);
if (!empty($data)) {
$indexKey = self::indexKey(
Resource::from($data['resource']),
$data['resourceId'],
);
$this->redis->zRem($indexKey, $id);
}
$this->redis->del($key);
}
private static function indexKey(Resource $resource, string $resourceId): string
{
return self::PREFIX_INDEX . $resource->value . ':' . $resourceId;
}
private static function toLog(array $data): Log
{
return new Log(
resource: Resource::from($data['resource']),
resourceId: $data['resourceId'],
timestamp: (float) $data['timestamp'],
durationSeconds: (float) $data['durationSeconds'],
requestMethod: Method::from($data['requestMethod']),
requestScheme: $data['requestScheme'],
requestHost: $data['requestHost'],
requestPath: $data['requestPath'],
requestQuery: $data['requestQuery'],
requestSizeBytes: (int) $data['requestSizeBytes'],
responseStatusCode: (int) $data['responseStatusCode'],
responseSizeBytes: (int) $data['responseSizeBytes'],
);
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Appwrite\Logs;
enum Resource: string
{
case Project = 'project';
case Deployment = 'deployment';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Appwrite\Logs;
enum Trigger: string
{
case Api = 'api';
case Http = 'http';
case Schedule = 'schedule';
case Event = 'event';
}
+2
View File
@@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core;
use Appwrite\Platform\Modules\Databases;
use Appwrite\Platform\Modules\Functions;
use Appwrite\Platform\Modules\Health;
use Appwrite\Platform\Modules\Logs;
use Appwrite\Platform\Modules\Projects;
use Appwrite\Platform\Modules\Proxy;
use Appwrite\Platform\Modules\Sites;
@@ -34,5 +35,6 @@ class Appwrite extends Platform
$this->addModule(new Tokens\Module());
$this->addModule(new Storage\Module());
$this->addModule(new VCS\Module());
$this->addModule(new Logs\Module());
}
}
@@ -4,6 +4,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Executions;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -17,6 +19,7 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
class Delete extends Base
{
@@ -62,6 +65,7 @@ class Delete extends Base
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('authorization')
->inject('logs')
->callback($this->action(...));
}
@@ -72,7 +76,8 @@ class Delete extends Base
Database $dbForProject,
Database $dbForPlatform,
Event $queueForEvents,
Authorization $authorization
Authorization $authorization,
Logs $logs,
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -80,6 +85,34 @@ class Delete extends Base
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$log = $logs->get($executionId);
if ($log === null) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
if ($log->resource !== Resource::Deployment) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $log->resourceId));
if ($deployment->isEmpty() || $deployment->getAttribute('resourceId') !== $functionId) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
$logs->delete($executionId);
$queueForEvents
->setParam('functionId', $function->getId())
->setParam('executionId', $executionId);
$response->noContent();
return;
}
$execution = $dbForProject->getDocument('executions', $executionId);
if ($execution->isEmpty()) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
@@ -3,6 +3,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Executions;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -10,10 +12,12 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
class Get extends Base
{
@@ -53,6 +57,7 @@ class Get extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('logs')
->callback($this->action(...));
}
@@ -61,7 +66,8 @@ class Get extends Base
string $executionId,
Response $response,
Database $dbForProject,
Authorization $authorization
Authorization $authorization,
Logs $logs,
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
@@ -72,6 +78,45 @@ class Get extends Base
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$log = $logs->get($executionId);
if ($log === null) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
if ($log->resource !== Resource::Deployment) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $log->resourceId));
if ($deployment->isEmpty() || $deployment->getAttribute('resourceId') !== $functionId) {
throw new Exception(Exception::EXECUTION_NOT_FOUND);
}
$response->dynamic(new Document([
'$id' => $executionId,
'$createdAt' => \date('Y-m-d\TH:i:s.vP', (int) $log->timestamp),
'$permissions' => [],
'functionId' => $functionId,
'deploymentId' => $log->resourceId,
'trigger' => 'http',
'status' => $log->responseStatusCode >= 500 ? 'failed' : 'completed',
'requestMethod' => $log->requestMethod->value,
'requestPath' => $log->requestPath,
'requestHeaders' => [],
'responseStatusCode' => $log->responseStatusCode,
'responseBody' => '',
'responseHeaders' => [],
'logs' => '',
'errors' => '',
'duration' => $log->durationSeconds,
]), Response::MODEL_EXECUTION);
return;
}
$execution = $dbForProject->getDocument('executions', $executionId);
if ($execution->getAttribute('resourceType') !== 'functions' || $execution->getAttribute('resourceInternalId') !== $function->getSequence()) {
@@ -3,6 +3,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Executions;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -20,6 +22,7 @@ use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Boolean;
class XList extends Base
@@ -61,6 +64,7 @@ class XList extends Base
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('logs')
->callback($this->action(...));
}
@@ -70,7 +74,8 @@ class XList extends Base
bool $includeTotal,
Response $response,
Database $dbForProject,
Authorization $authorization
Authorization $authorization,
Logs $logs,
) {
$function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId));
@@ -81,6 +86,69 @@ class XList extends Base
throw new Exception(Exception::FUNCTION_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$deploymentId = $function->getAttribute('deploymentId', '');
if (empty($deploymentId)) {
$response->dynamic(new Document([
'executions' => [],
'total' => 0,
]), Response::MODEL_EXECUTION_LIST);
return;
}
try {
$parsed = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$limit = Query::getByType($parsed, [Query::TYPE_LIMIT])[0]?->getValue() ?? 25;
$offset = Query::getByType($parsed, [Query::TYPE_OFFSET])[0]?->getValue() ?? 0;
$results = $logs->list(
resource: Resource::Deployment,
resourceId: $deploymentId,
limit: $limit,
offset: $offset,
);
$total = $includeTotal ? $logs->count(
resource: Resource::Deployment,
resourceId: $deploymentId,
) : 0;
$executions = [];
foreach ($results as $id => $log) {
$executions[] = new Document([
'$id' => $id,
'$createdAt' => \date('Y-m-d\TH:i:s.vP', (int) $log->timestamp),
'$permissions' => [],
'functionId' => $functionId,
'deploymentId' => $log->resourceId,
'trigger' => 'http',
'status' => $log->responseStatusCode >= 500 ? 'failed' : 'completed',
'requestMethod' => $log->requestMethod->value,
'requestPath' => $log->requestPath,
'requestHeaders' => [],
'responseStatusCode' => $log->responseStatusCode,
'responseBody' => '',
'responseHeaders' => [],
'logs' => '',
'errors' => '',
'duration' => $log->durationSeconds,
]);
}
$response->dynamic(new Document([
'executions' => $executions,
'total' => $total,
]), Response::MODEL_EXECUTION_LIST);
return;
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
@@ -0,0 +1,91 @@
<?php
namespace Appwrite\Platform\Modules\Logs\Http;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteHttpLog';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
->setHttpPath('/v1/logs/:logId')
->desc('Delete HTTP log')
->groups(['api', 'logs'])
->label('scope', 'log.write')
->label('sdk', new Method(
namespace: 'logs',
group: 'logs',
name: 'delete',
description: <<<EOT
Delete an HTTP log by its unique ID.
EOT,
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->param('logId', '', new UID(), 'Log ID.')
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('logs')
->callback($this->action(...));
}
public function action(
string $logId,
Response $response,
Document $project,
Database $dbForProject,
Logs $logs,
) {
$log = $logs->get($logId);
if ($log === null) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
switch ($log->resource) {
case Resource::Project:
if ($log->resourceId !== $project->getId()) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN);
}
break;
case Resource::Deployment:
$deployment = $dbForProject->getDocument('deployments', $log->resourceId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN);
}
break;
}
$logs->delete($logId);
$response->noContent();
}
}
@@ -0,0 +1,100 @@
<?php
namespace Appwrite\Platform\Modules\Logs\Http;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getHttpLog';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/logs/:logId')
->desc('Get HTTP log')
->groups(['api', 'logs'])
->label('scope', 'log.read')
->label('sdk', new Method(
namespace: 'logs',
group: 'logs',
name: 'get',
description: <<<EOT
Get an HTTP log by its unique ID.
EOT,
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_HTTP_LOG,
)
]
))
->param('logId', '', new UID(), 'Log ID.')
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('logs')
->callback($this->action(...));
}
public function action(
string $logId,
Response $response,
Document $project,
Database $dbForProject,
Logs $logs,
) {
$log = $logs->get($logId);
if ($log === null) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
switch ($log->resource) {
case Resource::Project:
if ($log->resourceId !== $project->getId()) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN);
}
break;
case Resource::Deployment:
$deployment = $dbForProject->getDocument('deployments', $log->resourceId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN);
}
break;
}
$response->dynamic(new Document([
'$id' => $logId,
'resource' => $log->resource->value,
'resourceId' => $log->resourceId,
'durationSeconds' => $log->durationSeconds,
'requestMethod' => $log->requestMethod->value,
'requestScheme' => $log->requestScheme,
'requestHost' => $log->requestHost,
'requestPath' => $log->requestPath,
'requestQuery' => $log->requestQuery,
'requestSizeBytes' => $log->requestSizeBytes,
'responseStatusCode' => $log->responseStatusCode,
'responseSizeBytes' => $log->responseSizeBytes,
]), Response::MODEL_HTTP_LOG);
}
}
@@ -0,0 +1,126 @@
<?php
namespace Appwrite\Platform\Modules\Logs\Http;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Integer;
use Utopia\Validator\WhiteList;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listHttpLogs';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/logs')
->desc('List HTTP logs')
->groups(['api', 'logs'])
->label('scope', 'log.read')
->label('sdk', new Method(
namespace: 'logs',
group: 'logs',
name: 'list',
description: <<<EOT
List HTTP logs for a resource. You can filter by resource type and resource ID.
EOT,
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_HTTP_LOG_LIST,
)
]
))
->param('resource', '', new WhiteList([Resource::Project->value, Resource::Deployment->value]), 'Resource type. Possible values: `project`, `deployment`.')
->param('resourceId', '', new \Utopia\Validator\Text(512), 'Resource ID.')
->param('limit', 100, new Integer(), 'Maximum number of logs to return. Maximum value is 100.', true)
->param('offset', 0, new Integer(), 'Offset value. The default value is 0.', true)
->inject('response')
->inject('project')
->inject('dbForProject')
->inject('logs')
->callback($this->action(...));
}
public function action(
string $resource,
string $resourceId,
int $limit,
int $offset,
Response $response,
Document $project,
Database $dbForProject,
Logs $logs,
) {
$resourceEnum = Resource::from($resource);
switch ($resourceEnum) {
case Resource::Project:
if ($resourceId !== $project->getId()) {
throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN);
}
break;
case Resource::Deployment:
$deployment = $dbForProject->getDocument('deployments', $resourceId);
if ($deployment->isEmpty()) {
throw new Exception(Exception::DEPLOYMENT_NOT_FOUND);
}
break;
}
$limit = \min($limit, 100);
$offset = \max($offset, 0);
$results = $logs->list(
resource: $resourceEnum,
resourceId: $resourceId,
limit: $limit,
offset: $offset,
);
$total = $logs->count(
resource: $resourceEnum,
resourceId: $resourceId,
);
$documents = [];
foreach ($results as $id => $log) {
$documents[] = new Document([
'$id' => $id,
'resource' => $log->resource->value,
'resourceId' => $log->resourceId,
'durationSeconds' => $log->durationSeconds,
'requestMethod' => $log->requestMethod->value,
'requestScheme' => $log->requestScheme,
'requestHost' => $log->requestHost,
'requestPath' => $log->requestPath,
'requestQuery' => $log->requestQuery,
'requestSizeBytes' => $log->requestSizeBytes,
'responseStatusCode' => $log->responseStatusCode,
'responseSizeBytes' => $log->responseSizeBytes,
]);
}
$response->dynamic(new Document([
'httpLogs' => $documents,
'total' => $total,
]), Response::MODEL_HTTP_LOG_LIST);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Appwrite\Platform\Modules\Logs;
use Appwrite\Platform\Modules\Logs\Services\Http;
use Utopia\Platform;
class Module extends Platform\Module
{
public function __construct()
{
$this->addService('http', new Http());
}
}
@@ -0,0 +1,20 @@
<?php
namespace Appwrite\Platform\Modules\Logs\Services;
use Appwrite\Platform\Modules\Logs\Http\Delete;
use Appwrite\Platform\Modules\Logs\Http\Get;
use Appwrite\Platform\Modules\Logs\Http\XList;
use Utopia\Platform\Service;
class Http extends Service
{
public function __construct()
{
$this->type = Service::TYPE_HTTP;
$this->addAction(XList::getName(), new XList());
$this->addAction(Get::getName(), new Get());
$this->addAction(Delete::getName(), new Delete());
}
}
@@ -4,15 +4,19 @@ namespace Appwrite\Platform\Modules\Sites\Http\Logs;
use Appwrite\Event\Event;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
class Delete extends Base
{
@@ -55,17 +59,54 @@ class Delete extends Base
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
->inject('authorization')
->inject('logs')
->callback($this->action(...));
}
public function action(string $siteId, string $logId, Response $response, Database $dbForProject, Event $queueForEvents)
{
public function action(
string $siteId,
string $logId,
Response $response,
Database $dbForProject,
Event $queueForEvents,
Authorization $authorization,
Logs $logs,
) {
$site = $dbForProject->getDocument('sites', $siteId);
if ($site->isEmpty()) {
throw new Exception(Exception::SITE_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$log = $logs->get($logId);
if ($log === null) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
if ($log->resource !== Resource::Deployment) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $log->resourceId));
if ($deployment->isEmpty() || $deployment->getAttribute('resourceId') !== $siteId) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
$logs->delete($logId);
$queueForEvents
->setParam('siteId', $site->getId())
->setParam('logId', $logId);
$response->noContent();
return;
}
$log = $dbForProject->getDocument('executions', $logId);
if ($log->isEmpty()) {
throw new Exception(Exception::LOG_NOT_FOUND);
@@ -82,7 +123,7 @@ class Delete extends Base
$queueForEvents
->setParam('siteId', $site->getId())
->setParam('logId', $log->getId())
->setPayload($response->output($log, Response::MODEL_EXECUTION)); // TODO: Update model
->setPayload($response->output($log, Response::MODEL_EXECUTION));
$response->noContent();
}
@@ -3,15 +3,20 @@
namespace Appwrite\Platform\Modules\Sites\Http\Logs;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
class Get extends Base
{
@@ -50,17 +55,64 @@ class Get extends Base
->param('logId', '', new UID(), 'Log ID.')
->inject('response')
->inject('dbForProject')
->inject('authorization')
->inject('logs')
->callback($this->action(...));
}
public function action(string $siteId, string $logId, Response $response, Database $dbForProject)
{
public function action(
string $siteId,
string $logId,
Response $response,
Database $dbForProject,
Authorization $authorization,
Logs $logs,
) {
$site = $dbForProject->getDocument('sites', $siteId);
if ($site->isEmpty() || !$site->getAttribute('enabled')) {
throw new Exception(Exception::SITE_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$log = $logs->get($logId);
if ($log === null) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
if ($log->resource !== Resource::Deployment) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
$deployment = $authorization->skip(fn () => $dbForProject->getDocument('deployments', $log->resourceId));
if ($deployment->isEmpty() || $deployment->getAttribute('resourceId') !== $siteId) {
throw new Exception(Exception::LOG_NOT_FOUND);
}
$response->dynamic(new Document([
'$id' => $logId,
'$createdAt' => \date('Y-m-d\TH:i:s.vP', (int) $log->timestamp),
'$permissions' => [],
'resourceId' => $siteId,
'deploymentId' => $log->resourceId,
'trigger' => 'http',
'status' => $log->responseStatusCode >= 500 ? 'failed' : 'completed',
'requestMethod' => $log->requestMethod->value,
'requestPath' => $log->requestPath,
'requestHeaders' => [],
'responseStatusCode' => $log->responseStatusCode,
'responseBody' => '',
'responseHeaders' => [],
'logs' => '',
'errors' => '',
'duration' => $log->durationSeconds,
]), Response::MODEL_EXECUTION);
return;
}
$log = $dbForProject->getDocument('executions', $logId);
if ($log->getAttribute('resourceType') !== 'sites' && $log->getAttribute('resourceInternalId') !== $site->getSequence()) {
@@ -71,6 +123,6 @@ class Get extends Base
throw new Exception(Exception::LOG_NOT_FOUND);
}
$response->dynamic($log, Response::MODEL_EXECUTION); //TODO: Change to model log, but model log already exists - decide what to do
$response->dynamic($log, Response::MODEL_EXECUTION);
}
}
@@ -3,12 +3,13 @@
namespace Appwrite\Platform\Modules\Sites\Http\Logs;
use Appwrite\Extend\Exception;
use Appwrite\Logs;
use Appwrite\Logs\Resource;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Validator\Queries\Executions;
use Appwrite\Utopia\Database\Validator\Queries\Logs;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -19,6 +20,7 @@ use Utopia\Database\Validator\Query\Cursor;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Boolean;
class XList extends Base
@@ -55,21 +57,91 @@ class XList extends Base
]
))
->param('siteId', '', new UID(), 'Site ID.')
->param('queries', [], new Logs(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Executions::ALLOWED_ATTRIBUTES), true)
->param('queries', [], new \Appwrite\Utopia\Database\Validator\Queries\Logs(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Executions::ALLOWED_ATTRIBUTES), true)
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
->inject('response')
->inject('dbForProject')
->inject('logs')
->callback($this->action(...));
}
public function action(string $siteId, array $queries, bool $includeTotal, Response $response, Database $dbForProject)
{
public function action(
string $siteId,
array $queries,
bool $includeTotal,
Response $response,
Database $dbForProject,
Logs $logs,
) {
$site = $dbForProject->getDocument('sites', $siteId);
if ($site->isEmpty() || !$site->getAttribute('enabled')) {
throw new Exception(Exception::SITE_NOT_FOUND);
}
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$deploymentId = $site->getAttribute('deploymentId', '');
if (empty($deploymentId)) {
$response->dynamic(new Document([
'executions' => [],
'total' => 0,
]), Response::MODEL_EXECUTION_LIST);
return;
}
try {
$parsed = Query::parseQueries($queries);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
$limit = Query::getByType($parsed, [Query::TYPE_LIMIT])[0]?->getValue() ?? 25;
$offset = Query::getByType($parsed, [Query::TYPE_OFFSET])[0]?->getValue() ?? 0;
$results = $logs->list(
resource: Resource::Deployment,
resourceId: $deploymentId,
limit: $limit,
offset: $offset,
);
$total = $includeTotal ? $logs->count(
resource: Resource::Deployment,
resourceId: $deploymentId,
) : 0;
$executions = [];
foreach ($results as $id => $log) {
$executions[] = new Document([
'$id' => $id,
'$createdAt' => \date('Y-m-d\TH:i:s.vP', (int) $log->timestamp),
'$permissions' => [],
'resourceId' => $siteId,
'deploymentId' => $log->resourceId,
'trigger' => 'http',
'status' => $log->responseStatusCode >= 500 ? 'failed' : 'completed',
'requestMethod' => $log->requestMethod->value,
'requestPath' => $log->requestPath,
'requestHeaders' => [],
'responseStatusCode' => $log->responseStatusCode,
'responseBody' => '',
'responseHeaders' => [],
'logs' => '',
'errors' => '',
'duration' => $log->durationSeconds,
]);
}
$response->dynamic(new Document([
'executions' => $executions,
'total' => $total,
]), Response::MODEL_EXECUTION_LIST);
return;
}
try {
$queries = Query::parseQueries($queries);
} catch (QueryException $e) {
+29 -1
View File
@@ -2,6 +2,10 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Logs;
use Appwrite\Logs\Log;
use Appwrite\Logs\Resource;
use Appwrite\Logs\Method;
use Exception;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -26,12 +30,14 @@ class Executions extends Action
->groups(['executions'])
->inject('message')
->inject('dbForProject')
->inject('logs')
->callback($this->action(...));
}
public function action(
Message $message,
Database $dbForProject,
Logs $logs
): void {
$payload = $message->getPayload() ?? [];
@@ -45,7 +51,29 @@ class Executions extends Action
throw new Exception('Missing execution');
}
if (System::getEnv('_APP_REGION') !== 'nyc') { // TODO: Remove region check
if (System::getEnv('FEATURE_LOGS', 'enabled') === 'enabled') {
$logs->append(new Log(
// Meta
resource: Resource::Deployment,
resourceId: $execution->getAttribute('deploymentId'),
timestamp: microtime(true),
durationSeconds: $execution->getAttribute('duration'),
// Request
requestMethod: Method::tryFrom($execution->getAttribute('requestMethod')),
requestScheme: '',
requestHost: '',
requestPath: $execution->getAttribute('requestPath'),
requestQuery: '',
requestSizeBytes: 0,
// Response
responseStatusCode: $execution->getAttribute('responseStatusCode'),
responseSizeBytes: 0,
));
} else {
$dbForProject->upsertDocument('executions', $execution);
}
}
+2
View File
@@ -210,6 +210,8 @@ class Response extends SwooleResponse
public const MODEL_DEPLOYMENT_LIST = 'deploymentList';
public const MODEL_EXECUTION = 'execution';
public const MODEL_EXECUTION_LIST = 'executionList';
public const MODEL_HTTP_LOG = 'httpLog';
public const MODEL_HTTP_LOG_LIST = 'httpLogList';
public const MODEL_FUNC_PERMISSIONS = 'funcPermissions';
public const MODEL_HEADERS = 'headers';
public const MODEL_SPECIFICATION = 'specification';
@@ -0,0 +1,96 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class HttpLog extends Model
{
public function __construct()
{
$this
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Log ID.',
'default' => '',
'example' => '5e5ea5c16897e',
])
->addRule('resource', [
'type' => self::TYPE_STRING,
'description' => 'Resource type. Possible values: `project`, `deployment`.',
'default' => '',
'example' => 'deployment',
])
->addRule('resourceId', [
'type' => self::TYPE_STRING,
'description' => 'Resource ID.',
'default' => '',
'example' => '5e5ea6g16897e',
])
->addRule('durationSeconds', [
'type' => self::TYPE_INTEGER,
'description' => 'Request duration in seconds.',
'default' => 0,
'example' => 1,
])
->addRule('requestMethod', [
'type' => self::TYPE_STRING,
'description' => 'HTTP request method.',
'default' => '',
'example' => 'GET',
])
->addRule('requestScheme', [
'type' => self::TYPE_STRING,
'description' => 'HTTP request scheme.',
'default' => '',
'example' => 'https',
])
->addRule('requestHost', [
'type' => self::TYPE_STRING,
'description' => 'HTTP request host.',
'default' => '',
'example' => 'example.com',
])
->addRule('requestPath', [
'type' => self::TYPE_STRING,
'description' => 'HTTP request path.',
'default' => '',
'example' => '/articles',
])
->addRule('requestQuery', [
'type' => self::TYPE_STRING,
'description' => 'HTTP request query string.',
'default' => '',
'example' => 'id=5',
])
->addRule('requestSizeBytes', [
'type' => self::TYPE_INTEGER,
'description' => 'HTTP request size in bytes.',
'default' => 0,
'example' => 256,
])
->addRule('responseStatusCode', [
'type' => self::TYPE_INTEGER,
'description' => 'HTTP response status code.',
'default' => 0,
'example' => 200,
])
->addRule('responseSizeBytes', [
'type' => self::TYPE_INTEGER,
'description' => 'HTTP response size in bytes.',
'default' => 0,
'example' => 1024,
]);
}
public function getName(): string
{
return 'HttpLog';
}
public function getType(): string
{
return Response::MODEL_HTTP_LOG;
}
}
@@ -0,0 +1,154 @@
<?php
namespace Tests\E2E\Services\Logs;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class LogsConsoleClientTest extends Scope
{
use ProjectCustom;
use SideConsole;
public function testListLogsByProject(): void
{
$projectId = $this->getProject()['$id'];
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'project',
'resourceId' => $projectId,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']['httpLogs']);
$this->assertIsInt($response['body']['total']);
/**
* Test for FAILURE - wrong project ID
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'project',
'resourceId' => 'invalid-project-id',
]);
$this->assertEquals(403, $response['headers']['status-code']);
/**
* Test for FAILURE - missing resource
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resourceId' => $projectId,
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - missing resourceId
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'project',
]);
$this->assertEquals(400, $response['headers']['status-code']);
/**
* Test for FAILURE - invalid resource type
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'invalid',
'resourceId' => $projectId,
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
public function testListLogsByDeployment(): void
{
$projectId = $this->getProject()['$id'];
/**
* Test for FAILURE - deployment not found
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'deployment',
'resourceId' => 'nonexistent-deployment',
]);
$this->assertEquals(404, $response['headers']['status-code']);
}
public function testListLogsPagination(): void
{
$projectId = $this->getProject()['$id'];
/**
* Test for SUCCESS - custom limit and offset
*/
$response = $this->client->call(Client::METHOD_GET, '/logs', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'resource' => 'project',
'resourceId' => $projectId,
'limit' => 10,
'offset' => 0,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']['httpLogs']);
$this->assertLessThanOrEqual(10, count($response['body']['httpLogs']));
}
public function testGetLog(): void
{
$projectId = $this->getProject()['$id'];
/**
* Test for FAILURE - log not found
*/
$response = $this->client->call(Client::METHOD_GET, '/logs/nonexistent-log-id', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
}
public function testDeleteLog(): void
{
$projectId = $this->getProject()['$id'];
/**
* Test for FAILURE - log not found
*/
$response = $this->client->call(Client::METHOD_DELETE, '/logs/nonexistent-log-id', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()));
$this->assertEquals(404, $response['headers']['status-code']);
}
}