@serhii.mazur/directus-gu-logs 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +112 -53
- package/dist/index.d.ts +3 -13
- package/dist/index.js +2 -133
- package/dist/indexNew.d.ts +3 -0
- package/dist/indexNew.js +2 -0
- package/dist/services/DirectusNotifier.d.ts +13 -0
- package/dist/services/DirectusNotifier.js +77 -0
- package/dist/services/Logs.d.ts +21 -0
- package/dist/services/Logs.js +96 -0
- package/dist/services/SlackNotifier.d.ts +9 -0
- package/dist/services/SlackNotifier.js +53 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/src/index.ts +11 -177
- package/src/services/DirectusNotifier.ts +100 -0
- package/src/services/Logs.ts +122 -0
- package/src/services/SlackNotifier.ts +61 -0
- package/src/types.ts +35 -0
package/README.MD
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# 📦 Custom Directus Logs Utility
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A modular utility for logging errors, tracking activity, and sending notifications (Directus in-app + Slack) from Directus extensions.
|
|
4
4
|
|
|
5
|
-
## 📋 Requirements
|
|
5
|
+
## 📋 Requirements
|
|
6
6
|
|
|
7
|
-
### 1. Logs Collection (logs)
|
|
7
|
+
### 1. Logs Collection (`logs`)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Create a collection named `logs` with the following fields:
|
|
10
10
|
|
|
11
11
|
| Field Name | Type | Description |
|
|
12
12
|
| --------------- | --------- | ---------------------------- |
|
|
@@ -16,24 +16,37 @@ You need to create a collection named `logs` with the following fields:
|
|
|
16
16
|
| `function_name` | string | Name of the function |
|
|
17
17
|
| `error` | text/code | Error message or stack trace |
|
|
18
18
|
|
|
19
|
-
### 2. Global Collection (global)
|
|
19
|
+
### 2. Global Collection (`global`)
|
|
20
20
|
|
|
21
|
-
Add the following field to the `global` collection:
|
|
21
|
+
Add the following field to the `global` singleton collection:
|
|
22
22
|
|
|
23
|
-
| Field Name | Type | Description
|
|
24
|
-
| ------------------------ | ------------------------------- |
|
|
25
|
-
| `error_notice_recipient` | m2m → directus_users (junction) | Recipients for error notifications
|
|
23
|
+
| Field Name | Type | Description |
|
|
24
|
+
| ------------------------ | ------------------------------- | -------------------------------------------- |
|
|
25
|
+
| `error_notice_recipient` | m2m → directus_users (junction) | Recipients for error notifications |
|
|
26
|
+
| `slack_webhook_url` | input | Incoming webhook URL for Slack notifications |
|
|
26
27
|
|
|
27
|
-
All linked users
|
|
28
|
+
All linked users will receive internal Directus notifications when errors occur (unless a recipient override is passed).
|
|
29
|
+
|
|
30
|
+
### 3. Environment Variables
|
|
31
|
+
|
|
32
|
+
| Variable | Required | Description |
|
|
33
|
+
| ------------- | -------- | ------------------------------------------------------------- |
|
|
34
|
+
| `BACKEND_URL` | Optional | Shown in notifications; falls back to `PUBLIC_URL` |
|
|
35
|
+
| `BRANCH` | Optional | Environment label (e.g. `main`, `staging`); defaults to `dev` |
|
|
36
|
+
|
|
37
|
+
---
|
|
28
38
|
|
|
29
39
|
## 🚀 Features
|
|
30
40
|
|
|
31
|
-
-
|
|
41
|
+
- Persist structured error logs to a Directus collection
|
|
42
|
+
- Send Slack Block Kit notifications via incoming webhook
|
|
43
|
+
- Send Directus in-app notifications with automatic recipient resolution
|
|
32
44
|
- Create Directus activity records
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
45
|
+
- Shared project metadata (name, URL, environment) injected into all notifications
|
|
46
|
+
- Recipient deduplication
|
|
47
|
+
- Graceful fallbacks — all methods catch and log their own errors
|
|
48
|
+
|
|
49
|
+
---
|
|
37
50
|
|
|
38
51
|
## 📦 Installation
|
|
39
52
|
|
|
@@ -41,36 +54,84 @@ All linked users from this field will receive internal notifications when errors
|
|
|
41
54
|
npm i @serhii.mazur/directus-gu-logs
|
|
42
55
|
```
|
|
43
56
|
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 🗂 Architecture
|
|
60
|
+
|
|
61
|
+
The package is split into three focused classes:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Logs ← main entry point; orchestrates everything
|
|
65
|
+
├── SlackNotifier ← formats and sends Slack Block Kit payloads
|
|
66
|
+
└── DirectusNotifier ← resolves recipients, sends in-app notifications
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
All three are exported and can be used independently if needed.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
44
73
|
## 🧩 Usage
|
|
45
74
|
|
|
75
|
+
### Basic (via `Logs`)
|
|
76
|
+
|
|
46
77
|
```ts
|
|
47
78
|
import { Logs } from "@serhii.mazur/directus-gu-logs";
|
|
48
79
|
|
|
49
80
|
const logs = new Logs(context, "my-extension");
|
|
50
81
|
|
|
51
|
-
//
|
|
82
|
+
// Persist error log to DB + notify Slack
|
|
52
83
|
await logs.printLogs("myFunction", "Something went wrong");
|
|
53
84
|
|
|
54
|
-
//
|
|
85
|
+
// Persist error log without Slack notification
|
|
86
|
+
await logs.printLogs("myFunction", "Something went wrong", false);
|
|
87
|
+
|
|
88
|
+
// Create a Directus activity record
|
|
55
89
|
await logs.createActivity("create", "collection_name", "item_id");
|
|
56
90
|
|
|
57
|
-
// Send notification (
|
|
91
|
+
// Send Directus in-app notification (recipients from global settings)
|
|
58
92
|
await logs.createNotification("An error occurred");
|
|
59
93
|
|
|
60
|
-
//
|
|
94
|
+
// With custom subject
|
|
61
95
|
await logs.createNotification("An error occurred", "Custom Subject");
|
|
62
96
|
|
|
63
|
-
//
|
|
97
|
+
// Override recipient
|
|
64
98
|
await logs.createNotification("An error occurred", "Custom Subject", "user_id");
|
|
65
99
|
|
|
66
|
-
//
|
|
100
|
+
// With linked collection + item
|
|
67
101
|
await logs.createNotification("An error occurred", "Custom Subject", null, "collection_name", "item_id");
|
|
102
|
+
|
|
103
|
+
// Send Slack notification directly
|
|
104
|
+
await logs.createSlackNotification("Deploy failed on main", "CI Alert");
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Advanced (direct class usage)
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { SlackNotifier, DirectusNotifier } from "@serhii.mazur/directus-gu-logs";
|
|
111
|
+
|
|
112
|
+
// Use SlackNotifier standalone
|
|
113
|
+
const slack = new SlackNotifier(context, "my-extension");
|
|
114
|
+
await slack.notify("Custom message", "Alert Title", projectMeta);
|
|
115
|
+
|
|
116
|
+
// Send a raw Slack payload
|
|
117
|
+
await slack.send({ text: "plain fallback", blocks: [...] });
|
|
118
|
+
|
|
119
|
+
// Use DirectusNotifier standalone
|
|
120
|
+
const directus = new DirectusNotifier(context, "my-extension");
|
|
121
|
+
await directus.notify("Message", projectMeta, {
|
|
122
|
+
subject: "Custom Subject",
|
|
123
|
+
recipientOverride: "user-uuid",
|
|
124
|
+
collection: "pages",
|
|
125
|
+
item: "42",
|
|
126
|
+
});
|
|
68
127
|
```
|
|
69
128
|
|
|
70
129
|
---
|
|
71
130
|
|
|
72
131
|
## ⚙️ Constructor
|
|
73
132
|
|
|
133
|
+
### `Logs`
|
|
134
|
+
|
|
74
135
|
```ts
|
|
75
136
|
constructor(
|
|
76
137
|
context: ApiExtensionContext,
|
|
@@ -79,44 +140,42 @@ constructor(
|
|
|
79
140
|
)
|
|
80
141
|
```
|
|
81
142
|
|
|
82
|
-
| Param | Type | Description
|
|
83
|
-
| ---------------- | ------------------- |
|
|
84
|
-
| `context` | ApiExtensionContext | Directus extension context
|
|
85
|
-
| `extension` | string | Name of your extension
|
|
86
|
-
| `collectionName` | string
|
|
143
|
+
| Param | Type | Default | Description |
|
|
144
|
+
| ---------------- | ------------------- | ------- | -------------------------------- |
|
|
145
|
+
| `context` | ApiExtensionContext | — | Directus extension context |
|
|
146
|
+
| `extension` | string | — | Name of your extension |
|
|
147
|
+
| `collectionName` | string | `logs` | Target collection for error logs |
|
|
148
|
+
|
|
149
|
+
---
|
|
87
150
|
|
|
88
151
|
## 🧠 How It Works
|
|
89
152
|
|
|
90
|
-
###
|
|
153
|
+
### `printLogs(functionName, error, notifySlack?)`
|
|
91
154
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
155
|
+
1. Writes a structured entry to the logs collection
|
|
156
|
+
2. If `notifySlack` is `true` (default), sends a Slack Block Kit message with extension name, function, and error
|
|
157
|
+
3. Always writes to `context.logger.error` as well
|
|
158
|
+
4. Catches its own failure — a broken DB write won't throw
|
|
95
159
|
|
|
96
|
-
|
|
160
|
+
### `createActivity(action, collection, id)`
|
|
97
161
|
|
|
98
|
-
|
|
162
|
+
Creates a Directus activity record via `ActivityService`. User, IP, and user agent are set to `null` in extension hook context where no accountability is available.
|
|
99
163
|
|
|
100
|
-
|
|
101
|
-
- Automatically includes:
|
|
102
|
-
- user
|
|
103
|
-
- IP address
|
|
104
|
-
- user agent
|
|
105
|
-
- origin
|
|
164
|
+
### `createNotification(message, subject?, recipientOverride?, collection?, item?)`
|
|
106
165
|
|
|
107
|
-
|
|
166
|
+
Resolves recipients in this order:
|
|
167
|
+
|
|
168
|
+
1. `recipientOverride` (if provided)
|
|
169
|
+
2. `global.error_notice_recipient` linked users
|
|
170
|
+
|
|
171
|
+
Then sends one in-app notification per recipient. Each notification includes:
|
|
108
172
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
- Project name
|
|
119
|
-
- Backend URL
|
|
120
|
-
- Environment (branch)
|
|
121
|
-
- UTC timestamp
|
|
122
|
-
- Sends one notification per recipient
|
|
173
|
+
- Custom or default subject with the project name appended
|
|
174
|
+
- The message body
|
|
175
|
+
- Environment label, backend URL, and UTC timestamp
|
|
176
|
+
|
|
177
|
+
### `createSlackNotification(message, subject?)`
|
|
178
|
+
|
|
179
|
+
Sends a formatted Slack Block Kit message via `slack_webhook_url`. Silently skips if the var is not set. The payload includes project name, environment, extension name, backend URL, and the message body.
|
|
180
|
+
|
|
181
|
+
---
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
protected context: ApiExtensionContext;
|
|
5
|
-
protected extension: string;
|
|
6
|
-
protected collectionName: string;
|
|
7
|
-
constructor(context: ApiExtensionContext, extension: string, collectionName?: string);
|
|
8
|
-
private getSchema;
|
|
9
|
-
private createOne;
|
|
10
|
-
printLogs(functionName: string, error: string): Promise<void>;
|
|
11
|
-
createActivity(action: string, collection: string, id: PrimaryKey): Promise<void>;
|
|
12
|
-
createNotification(message: string, customSubject?: string | null, recipientOverride?: string | null, collection?: string | null, item?: string | null): Promise<void>;
|
|
13
|
-
}
|
|
1
|
+
export { Logs } from "./services/Logs";
|
|
2
|
+
export { SlackNotifier } from "./services/SlackNotifier";
|
|
3
|
+
export type { LogEntry, RecipientLinkRow, ProjectMeta, SlackBlock, SlackPayload, ApiExtensionContext, PrimaryKey, } from "./types";
|
package/dist/index.js
CHANGED
|
@@ -1,133 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
?.map(row => {
|
|
4
|
-
const user = row?.directus_users_id;
|
|
5
|
-
if (typeof user === "string")
|
|
6
|
-
return user;
|
|
7
|
-
return typeof user?.id === "string" ? user.id : null;
|
|
8
|
-
})
|
|
9
|
-
.filter((value) => Boolean(value)) ?? [];
|
|
10
|
-
return Array.from(new Set(recipients));
|
|
11
|
-
}
|
|
12
|
-
export class Logs {
|
|
13
|
-
constructor(context, extension, collectionName = "logs") {
|
|
14
|
-
this.context = context;
|
|
15
|
-
this.extension = extension;
|
|
16
|
-
this.collectionName = collectionName;
|
|
17
|
-
}
|
|
18
|
-
async getSchema() {
|
|
19
|
-
return await this.context.getSchema();
|
|
20
|
-
}
|
|
21
|
-
async createOne(data) {
|
|
22
|
-
const schema = await this.getSchema();
|
|
23
|
-
const itemsService = new this.context.services.ItemsService(this.collectionName, {
|
|
24
|
-
database: this.context.database,
|
|
25
|
-
schema,
|
|
26
|
-
});
|
|
27
|
-
return await itemsService.createOne(data);
|
|
28
|
-
}
|
|
29
|
-
async printLogs(functionName, error) {
|
|
30
|
-
const data = {
|
|
31
|
-
collection: this.collectionName,
|
|
32
|
-
date_created: new Date().toISOString(),
|
|
33
|
-
extension: this.extension,
|
|
34
|
-
function_name: functionName,
|
|
35
|
-
error,
|
|
36
|
-
};
|
|
37
|
-
try {
|
|
38
|
-
await this.createOne(data);
|
|
39
|
-
console.error(`🚀 [${this.extension}] ${functionName}:`, error);
|
|
40
|
-
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
console.error("❌ Failed to save logs:", error);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
async createActivity(action, collection, id) {
|
|
46
|
-
try {
|
|
47
|
-
const schema = await this.getSchema();
|
|
48
|
-
const services = this.context.services;
|
|
49
|
-
const accountability = services.accountability;
|
|
50
|
-
const activityService = new services.ActivityService({
|
|
51
|
-
schema: schema,
|
|
52
|
-
accountability: accountability,
|
|
53
|
-
knex: this.context.database,
|
|
54
|
-
});
|
|
55
|
-
await activityService.createOne({
|
|
56
|
-
action,
|
|
57
|
-
user: accountability?.user ?? null,
|
|
58
|
-
collection,
|
|
59
|
-
ip: accountability?.ip ?? null,
|
|
60
|
-
user_agent: accountability?.userAgent ?? null,
|
|
61
|
-
origin: accountability?.origin ?? null,
|
|
62
|
-
item: id,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
console.error("❌ Failed to create activity log:", error);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
async createNotification(message, customSubject = null, recipientOverride = null, collection = null, item = null) {
|
|
70
|
-
try {
|
|
71
|
-
const schema = await this.getSchema();
|
|
72
|
-
const { database, services } = this.context;
|
|
73
|
-
const globalService = new services.ItemsService("global", {
|
|
74
|
-
database,
|
|
75
|
-
schema,
|
|
76
|
-
});
|
|
77
|
-
// Check for passed recipient, fallback to global settings
|
|
78
|
-
let recipients = [];
|
|
79
|
-
if (recipientOverride) {
|
|
80
|
-
recipients = [recipientOverride];
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
const globalSettings = await globalService.readSingleton({
|
|
84
|
-
fields: ["error_notice_recipient.directus_users_id.id"],
|
|
85
|
-
});
|
|
86
|
-
recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
|
|
87
|
-
}
|
|
88
|
-
if (recipients.length === 0) {
|
|
89
|
-
this.printLogs(this.extension, "No recipients defined (override or global settings)");
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const notificationService = new services.NotificationsService({ schema });
|
|
93
|
-
// Project Data
|
|
94
|
-
const settings = await database.select("project_name").from("directus_settings").first();
|
|
95
|
-
const projectName = settings?.project_name || "Unknown Project";
|
|
96
|
-
const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
|
|
97
|
-
const environment = process.env.BRANCH || "dev";
|
|
98
|
-
const now = new Date();
|
|
99
|
-
const timestamp = new Intl.DateTimeFormat("en-US", {
|
|
100
|
-
month: "2-digit",
|
|
101
|
-
day: "2-digit",
|
|
102
|
-
year: "numeric",
|
|
103
|
-
hour: "2-digit",
|
|
104
|
-
minute: "2-digit",
|
|
105
|
-
hour12: false,
|
|
106
|
-
timeZone: "UTC",
|
|
107
|
-
}).format(now);
|
|
108
|
-
// Compose subject & message
|
|
109
|
-
const subject = customSubject
|
|
110
|
-
? `${customSubject} - ${projectName}`
|
|
111
|
-
: `Directus Error Notification - ${projectName}`;
|
|
112
|
-
const fullMessage = `
|
|
113
|
-
${message}<br><br>
|
|
114
|
-
<strong>Environment:</strong> ${environment}<br>
|
|
115
|
-
<strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
|
|
116
|
-
<strong>Date/Time (UTC):</strong> ${timestamp}
|
|
117
|
-
`.trim();
|
|
118
|
-
for (const recipient of recipients) {
|
|
119
|
-
await notificationService.createOne({
|
|
120
|
-
recipient,
|
|
121
|
-
sender: recipient,
|
|
122
|
-
subject,
|
|
123
|
-
message: fullMessage,
|
|
124
|
-
collection,
|
|
125
|
-
item,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
catch (error) {
|
|
130
|
-
console.error("❌ Failed to create notification:", error);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
1
|
+
export { Logs } from "./services/Logs";
|
|
2
|
+
export { SlackNotifier } from "./services/SlackNotifier";
|
package/dist/indexNew.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ApiExtensionContext, ProjectMeta } from "../types";
|
|
2
|
+
export declare class DirectusNotifier {
|
|
3
|
+
private readonly context;
|
|
4
|
+
private readonly extension;
|
|
5
|
+
constructor(context: ApiExtensionContext, extension: string);
|
|
6
|
+
private getSchema;
|
|
7
|
+
notify(message: string, meta: ProjectMeta, options?: {
|
|
8
|
+
subject?: string | null;
|
|
9
|
+
recipientOverride?: string | null;
|
|
10
|
+
collection?: string | null;
|
|
11
|
+
item?: string | null;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function extractRecipientIds(rows) {
|
|
2
|
+
const recipients = rows
|
|
3
|
+
?.map(row => {
|
|
4
|
+
const user = row?.directus_users_id;
|
|
5
|
+
if (typeof user === "string")
|
|
6
|
+
return user;
|
|
7
|
+
return typeof user?.id === "string" ? user.id : null;
|
|
8
|
+
})
|
|
9
|
+
.filter((v) => Boolean(v)) ?? [];
|
|
10
|
+
return Array.from(new Set(recipients));
|
|
11
|
+
}
|
|
12
|
+
export class DirectusNotifier {
|
|
13
|
+
constructor(context, extension) {
|
|
14
|
+
this.context = context;
|
|
15
|
+
this.extension = extension;
|
|
16
|
+
}
|
|
17
|
+
async getSchema() {
|
|
18
|
+
return this.context.getSchema();
|
|
19
|
+
}
|
|
20
|
+
async notify(message, meta, options = {}) {
|
|
21
|
+
const { subject = null, recipientOverride = null, collection = null, item = null } = options;
|
|
22
|
+
try {
|
|
23
|
+
const schema = await this.getSchema();
|
|
24
|
+
const { database, services } = this.context;
|
|
25
|
+
let recipients = [];
|
|
26
|
+
if (recipientOverride) {
|
|
27
|
+
recipients = [recipientOverride];
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const globalService = new services.ItemsService("global", { database, schema });
|
|
31
|
+
const globalSettings = await globalService.readSingleton({
|
|
32
|
+
fields: ["error_notice_recipient.directus_users_id.id"],
|
|
33
|
+
});
|
|
34
|
+
recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
|
|
35
|
+
}
|
|
36
|
+
if (recipients.length === 0) {
|
|
37
|
+
this.context.logger.warn({
|
|
38
|
+
msg: `[${this.extension}] No notification recipients defined (override or global settings)`,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const notificationService = new services.NotificationsService({ schema });
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const timestamp = new Intl.DateTimeFormat("en-US", {
|
|
45
|
+
month: "2-digit",
|
|
46
|
+
day: "2-digit",
|
|
47
|
+
year: "numeric",
|
|
48
|
+
hour: "2-digit",
|
|
49
|
+
minute: "2-digit",
|
|
50
|
+
hour12: false,
|
|
51
|
+
timeZone: "UTC",
|
|
52
|
+
}).format(now);
|
|
53
|
+
const resolvedSubject = subject
|
|
54
|
+
? `${subject} - ${meta.projectName}`
|
|
55
|
+
: `Directus Error Notification - ${meta.projectName}`;
|
|
56
|
+
const fullMessage = [
|
|
57
|
+
message,
|
|
58
|
+
`<strong>Environment:</strong> ${meta.environment}`,
|
|
59
|
+
`<strong>Backend URL:</strong> <a href="${meta.backendUrl}" target="_blank">${meta.backendUrl}</a>`,
|
|
60
|
+
`<strong>Date/Time (UTC):</strong> ${timestamp}`,
|
|
61
|
+
].join("<br><br>");
|
|
62
|
+
for (const recipient of recipients) {
|
|
63
|
+
await notificationService.createOne({
|
|
64
|
+
recipient,
|
|
65
|
+
sender: recipient,
|
|
66
|
+
subject: resolvedSubject,
|
|
67
|
+
message: fullMessage,
|
|
68
|
+
collection,
|
|
69
|
+
item,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.context.logger.error({ msg: "❌ Failed to send Directus notification", error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ApiExtensionContext, PrimaryKey } from "../types";
|
|
2
|
+
import { SlackNotifier } from "../services/SlackNotifier";
|
|
3
|
+
import { DirectusNotifier } from "./DirectusNotifier";
|
|
4
|
+
export declare class Logs {
|
|
5
|
+
protected readonly context: ApiExtensionContext;
|
|
6
|
+
protected readonly extension: string;
|
|
7
|
+
protected readonly collectionName: string;
|
|
8
|
+
protected readonly slack: SlackNotifier;
|
|
9
|
+
protected readonly directus: DirectusNotifier;
|
|
10
|
+
constructor(context: ApiExtensionContext, extension: string, collectionName?: string);
|
|
11
|
+
private getSchema;
|
|
12
|
+
private getProjectMeta;
|
|
13
|
+
private createOne;
|
|
14
|
+
/**
|
|
15
|
+
* Persists an error entry to the logs collection, optionally notifying via Slack.
|
|
16
|
+
*/
|
|
17
|
+
printLogs(functionName: string, error: string, notifySlack?: boolean): Promise<void>;
|
|
18
|
+
createActivity(action: string, collection: string, id: PrimaryKey): Promise<void>;
|
|
19
|
+
createNotification(message: string, customSubject?: string | null, recipientOverride?: string | null, collection?: string | null, item?: string | null): Promise<void>;
|
|
20
|
+
createSlackNotification(message: string, customSubject?: string | null): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { SlackNotifier } from "../services/SlackNotifier";
|
|
2
|
+
import { DirectusNotifier } from "./DirectusNotifier";
|
|
3
|
+
export class Logs {
|
|
4
|
+
constructor(context, extension, collectionName = "logs") {
|
|
5
|
+
this.context = context;
|
|
6
|
+
this.extension = extension;
|
|
7
|
+
this.collectionName = collectionName;
|
|
8
|
+
this.slack = new SlackNotifier(context, extension);
|
|
9
|
+
this.directus = new DirectusNotifier(context, extension);
|
|
10
|
+
}
|
|
11
|
+
async getSchema() {
|
|
12
|
+
return this.context.getSchema();
|
|
13
|
+
}
|
|
14
|
+
async getProjectMeta() {
|
|
15
|
+
const settings = await this.context.database.select("project_name").from("directus_settings").first();
|
|
16
|
+
return {
|
|
17
|
+
projectName: settings?.project_name ?? "Unknown Project",
|
|
18
|
+
backendUrl: process.env.BACKEND_URL ?? this.context.env?.PUBLIC_URL ?? "Unknown URL",
|
|
19
|
+
environment: process.env.BRANCH ?? "dev",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async createOne(data) {
|
|
23
|
+
const schema = await this.getSchema();
|
|
24
|
+
const itemsService = new this.context.services.ItemsService(this.collectionName, {
|
|
25
|
+
database: this.context.database,
|
|
26
|
+
schema,
|
|
27
|
+
});
|
|
28
|
+
return await itemsService.createOne(data);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Persists an error entry to the logs collection, optionally notifying via Slack.
|
|
32
|
+
*/
|
|
33
|
+
async printLogs(functionName, error, notifySlack = true) {
|
|
34
|
+
const data = {
|
|
35
|
+
collection: this.collectionName,
|
|
36
|
+
date_created: new Date().toISOString(),
|
|
37
|
+
extension: this.extension,
|
|
38
|
+
function_name: functionName,
|
|
39
|
+
error,
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await this.createOne(data);
|
|
43
|
+
this.context.logger.info({ msg: `🚀 [${this.extension}] ${functionName}:`, error });
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
this.context.logger.error({ msg: "❌ Failed to save log entry", error: err });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (notifySlack) {
|
|
51
|
+
const meta = await this.getProjectMeta();
|
|
52
|
+
await this.slack.notify(`*Function:* ${functionName}\n*Error:* ${error}`, "Extension Error", meta);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
this.context.logger.error({ msg: "Slack failed", err });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async createActivity(action, collection, id) {
|
|
60
|
+
try {
|
|
61
|
+
const schema = await this.getSchema();
|
|
62
|
+
const { services, database } = this.context;
|
|
63
|
+
const accountability = services.accountability;
|
|
64
|
+
const activityService = new services.ActivityService({
|
|
65
|
+
schema,
|
|
66
|
+
accountability,
|
|
67
|
+
knex: database,
|
|
68
|
+
});
|
|
69
|
+
await activityService.createOne({
|
|
70
|
+
action,
|
|
71
|
+
user: accountability?.user ?? null,
|
|
72
|
+
collection,
|
|
73
|
+
ip: accountability?.ip ?? null,
|
|
74
|
+
user_agent: accountability?.userAgent ?? null,
|
|
75
|
+
origin: accountability?.origin ?? null,
|
|
76
|
+
item: id,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
this.context.logger.error({ msg: "❌ Failed to create activity log", error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async createNotification(message, customSubject = null, recipientOverride = null, collection = null, item = null) {
|
|
84
|
+
const meta = await this.getProjectMeta();
|
|
85
|
+
await this.directus.notify(message, meta, {
|
|
86
|
+
subject: customSubject,
|
|
87
|
+
recipientOverride,
|
|
88
|
+
collection,
|
|
89
|
+
item,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async createSlackNotification(message, customSubject = null) {
|
|
93
|
+
const meta = await this.getProjectMeta();
|
|
94
|
+
await this.slack.notify(message, customSubject, meta);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ApiExtensionContext, ProjectMeta, SlackPayload } from "../types";
|
|
2
|
+
export declare class SlackNotifier {
|
|
3
|
+
private readonly context;
|
|
4
|
+
private readonly extension;
|
|
5
|
+
constructor(context: ApiExtensionContext, extension: string);
|
|
6
|
+
getWebHookUrl(): Promise<any>;
|
|
7
|
+
send(payload: SlackPayload): Promise<void>;
|
|
8
|
+
notify(message: string, subject: string | null | undefined, meta: ProjectMeta): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class SlackNotifier {
|
|
2
|
+
constructor(context, extension) {
|
|
3
|
+
this.context = context;
|
|
4
|
+
this.extension = extension;
|
|
5
|
+
}
|
|
6
|
+
async getWebHookUrl() {
|
|
7
|
+
const settings = await this.context.database.select("slack_webhook_url").from("global").first();
|
|
8
|
+
return settings?.slack_webhook_url;
|
|
9
|
+
}
|
|
10
|
+
async send(payload) {
|
|
11
|
+
const webhook = await this.getWebHookUrl();
|
|
12
|
+
if (!webhook) {
|
|
13
|
+
this.context.logger.info({ msg: "⚠️ SLACK_WEBHOOK_URL is not configured" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
await fetch(webhook, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify(payload),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
this.context.logger.error({ msg: "❌ Failed to send Slack message", error });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async notify(message, subject = null, meta) {
|
|
28
|
+
const title = subject ?? "Directus Error Notification";
|
|
29
|
+
await this.send({
|
|
30
|
+
text: title,
|
|
31
|
+
blocks: [
|
|
32
|
+
{
|
|
33
|
+
type: "header",
|
|
34
|
+
text: { type: "plain_text", text: `🚨 ${title}` },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "section",
|
|
38
|
+
fields: [
|
|
39
|
+
{ type: "mrkdwn", text: `*Project*\n${meta.projectName}` },
|
|
40
|
+
{ type: "mrkdwn", text: `*Environment*\n${meta.environment}` },
|
|
41
|
+
{ type: "mrkdwn", text: `*Extension*\n${this.extension}` },
|
|
42
|
+
{ type: "mrkdwn", text: `*Backend*\n${meta.backendUrl}` },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{ type: "divider" },
|
|
46
|
+
{
|
|
47
|
+
type: "section",
|
|
48
|
+
text: { type: "mrkdwn", text: message },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ApiExtensionContext } from "@directus/extensions";
|
|
2
|
+
import type { PrimaryKey } from "@directus/types";
|
|
3
|
+
export interface LogEntry {
|
|
4
|
+
collection: string;
|
|
5
|
+
date_created: string;
|
|
6
|
+
extension: string;
|
|
7
|
+
function_name: string;
|
|
8
|
+
error: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RecipientLinkRow {
|
|
11
|
+
directus_users_id?: string | {
|
|
12
|
+
id?: string | null;
|
|
13
|
+
} | null;
|
|
14
|
+
}
|
|
15
|
+
export interface ProjectMeta {
|
|
16
|
+
projectName: string;
|
|
17
|
+
backendUrl: string;
|
|
18
|
+
environment: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SlackBlock {
|
|
21
|
+
type: string;
|
|
22
|
+
text?: {
|
|
23
|
+
type: string;
|
|
24
|
+
text: string;
|
|
25
|
+
emoji?: boolean;
|
|
26
|
+
};
|
|
27
|
+
fields?: {
|
|
28
|
+
type: string;
|
|
29
|
+
text: string;
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
export interface SlackPayload {
|
|
33
|
+
text: string;
|
|
34
|
+
blocks?: SlackBlock[];
|
|
35
|
+
}
|
|
36
|
+
export type { ApiExtensionContext };
|
|
37
|
+
export type { PrimaryKey };
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,177 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
interface RecipientLinkRow {
|
|
13
|
-
directus_users_id?: string | { id?: string | null } | null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function extractRecipientIds(rows: RecipientLinkRow[] | undefined): string[] {
|
|
17
|
-
const recipients =
|
|
18
|
-
rows
|
|
19
|
-
?.map(row => {
|
|
20
|
-
const user = row?.directus_users_id;
|
|
21
|
-
if (typeof user === "string") return user;
|
|
22
|
-
return typeof user?.id === "string" ? user.id : null;
|
|
23
|
-
})
|
|
24
|
-
.filter((value): value is string => Boolean(value)) ?? [];
|
|
25
|
-
|
|
26
|
-
return Array.from(new Set(recipients));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class Logs {
|
|
30
|
-
protected context: ApiExtensionContext;
|
|
31
|
-
protected extension: string;
|
|
32
|
-
protected collectionName: string;
|
|
33
|
-
|
|
34
|
-
constructor(context: ApiExtensionContext, extension: string, collectionName: string = "logs") {
|
|
35
|
-
this.context = context;
|
|
36
|
-
this.extension = extension;
|
|
37
|
-
this.collectionName = collectionName;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
private async getSchema() {
|
|
41
|
-
return await this.context.getSchema();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private async createOne(data: Record<string, any>) {
|
|
45
|
-
const schema = await this.getSchema();
|
|
46
|
-
|
|
47
|
-
const itemsService = new this.context.services.ItemsService(this.collectionName, {
|
|
48
|
-
database: this.context.database,
|
|
49
|
-
schema,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return await itemsService.createOne(data);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async printLogs(functionName: string, error: string) {
|
|
56
|
-
const data: LogEntry = {
|
|
57
|
-
collection: this.collectionName,
|
|
58
|
-
date_created: new Date().toISOString(),
|
|
59
|
-
extension: this.extension,
|
|
60
|
-
function_name: functionName,
|
|
61
|
-
error,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
await this.createOne(data);
|
|
66
|
-
console.error(`🚀 [${this.extension}] ${functionName}:`, error);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
console.error("❌ Failed to save logs:", error);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async createActivity(action: string, collection: string, id: PrimaryKey) {
|
|
73
|
-
try {
|
|
74
|
-
const schema = await this.getSchema();
|
|
75
|
-
const services = this.context.services;
|
|
76
|
-
const accountability = services.accountability;
|
|
77
|
-
|
|
78
|
-
const activityService = new services.ActivityService({
|
|
79
|
-
schema: schema,
|
|
80
|
-
accountability: accountability,
|
|
81
|
-
knex: this.context.database,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
await activityService.createOne({
|
|
85
|
-
action,
|
|
86
|
-
user: accountability?.user ?? null,
|
|
87
|
-
collection,
|
|
88
|
-
ip: accountability?.ip ?? null,
|
|
89
|
-
user_agent: accountability?.userAgent ?? null,
|
|
90
|
-
origin: accountability?.origin ?? null,
|
|
91
|
-
item: id,
|
|
92
|
-
});
|
|
93
|
-
} catch (error) {
|
|
94
|
-
console.error("❌ Failed to create activity log:", error);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async createNotification(
|
|
99
|
-
message: string,
|
|
100
|
-
customSubject: string | null = null,
|
|
101
|
-
recipientOverride: string | null = null,
|
|
102
|
-
collection: string | null = null,
|
|
103
|
-
item: string | null = null
|
|
104
|
-
) {
|
|
105
|
-
try {
|
|
106
|
-
const schema = await this.getSchema();
|
|
107
|
-
const { database, services } = this.context;
|
|
108
|
-
const globalService = new services.ItemsService("global", {
|
|
109
|
-
database,
|
|
110
|
-
schema,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Check for passed recipient, fallback to global settings
|
|
114
|
-
let recipients: string[] = [];
|
|
115
|
-
|
|
116
|
-
if (recipientOverride) {
|
|
117
|
-
recipients = [recipientOverride];
|
|
118
|
-
} else {
|
|
119
|
-
const globalSettings = await globalService.readSingleton({
|
|
120
|
-
fields: ["error_notice_recipient.directus_users_id.id"],
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (recipients.length === 0) {
|
|
127
|
-
this.printLogs(this.extension, "No recipients defined (override or global settings)");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const notificationService = new services.NotificationsService({ schema });
|
|
132
|
-
|
|
133
|
-
// Project Data
|
|
134
|
-
const settings = await database.select("project_name").from("directus_settings").first();
|
|
135
|
-
|
|
136
|
-
const projectName = settings?.project_name || "Unknown Project";
|
|
137
|
-
const backendUrl = process.env.BACKEND_URL || this.context.env?.PUBLIC_URL || "Unknown URL";
|
|
138
|
-
const environment = process.env.BRANCH || "dev";
|
|
139
|
-
|
|
140
|
-
const now = new Date();
|
|
141
|
-
const timestamp = new Intl.DateTimeFormat("en-US", {
|
|
142
|
-
month: "2-digit",
|
|
143
|
-
day: "2-digit",
|
|
144
|
-
year: "numeric",
|
|
145
|
-
hour: "2-digit",
|
|
146
|
-
minute: "2-digit",
|
|
147
|
-
hour12: false,
|
|
148
|
-
timeZone: "UTC",
|
|
149
|
-
}).format(now);
|
|
150
|
-
|
|
151
|
-
// Compose subject & message
|
|
152
|
-
const subject = customSubject
|
|
153
|
-
? `${customSubject} - ${projectName}`
|
|
154
|
-
: `Directus Error Notification - ${projectName}`;
|
|
155
|
-
|
|
156
|
-
const fullMessage = `
|
|
157
|
-
${message}<br><br>
|
|
158
|
-
<strong>Environment:</strong> ${environment}<br>
|
|
159
|
-
<strong>Backend URL:</strong> <a href="${backendUrl}" target="_blank">${backendUrl}</a><br>
|
|
160
|
-
<strong>Date/Time (UTC):</strong> ${timestamp}
|
|
161
|
-
`.trim();
|
|
162
|
-
|
|
163
|
-
for (const recipient of recipients) {
|
|
164
|
-
await notificationService.createOne({
|
|
165
|
-
recipient,
|
|
166
|
-
sender: recipient,
|
|
167
|
-
subject,
|
|
168
|
-
message: fullMessage,
|
|
169
|
-
collection,
|
|
170
|
-
item,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
} catch (error) {
|
|
174
|
-
console.error("❌ Failed to create notification:", error);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
1
|
+
export { Logs } from "./services/Logs";
|
|
2
|
+
export { SlackNotifier } from "./services/SlackNotifier";
|
|
3
|
+
export type {
|
|
4
|
+
LogEntry,
|
|
5
|
+
RecipientLinkRow,
|
|
6
|
+
ProjectMeta,
|
|
7
|
+
SlackBlock,
|
|
8
|
+
SlackPayload,
|
|
9
|
+
ApiExtensionContext,
|
|
10
|
+
PrimaryKey,
|
|
11
|
+
} from "./types";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ApiExtensionContext, ProjectMeta, RecipientLinkRow } from "../types";
|
|
2
|
+
|
|
3
|
+
function extractRecipientIds(rows: RecipientLinkRow[] | undefined): string[] {
|
|
4
|
+
const recipients =
|
|
5
|
+
rows
|
|
6
|
+
?.map(row => {
|
|
7
|
+
const user = row?.directus_users_id;
|
|
8
|
+
if (typeof user === "string") return user;
|
|
9
|
+
return typeof user?.id === "string" ? user.id : null;
|
|
10
|
+
})
|
|
11
|
+
.filter((v): v is string => Boolean(v)) ?? [];
|
|
12
|
+
|
|
13
|
+
return Array.from(new Set(recipients));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class DirectusNotifier {
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly context: ApiExtensionContext,
|
|
19
|
+
private readonly extension: string,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
private async getSchema() {
|
|
23
|
+
return this.context.getSchema();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async notify(
|
|
27
|
+
message: string,
|
|
28
|
+
meta: ProjectMeta,
|
|
29
|
+
options: {
|
|
30
|
+
subject?: string | null;
|
|
31
|
+
recipientOverride?: string | null;
|
|
32
|
+
collection?: string | null;
|
|
33
|
+
item?: string | null;
|
|
34
|
+
} = {},
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const { subject = null, recipientOverride = null, collection = null, item = null } = options;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const schema = await this.getSchema();
|
|
40
|
+
const { database, services } = this.context;
|
|
41
|
+
|
|
42
|
+
let recipients: string[] = [];
|
|
43
|
+
|
|
44
|
+
if (recipientOverride) {
|
|
45
|
+
recipients = [recipientOverride];
|
|
46
|
+
} else {
|
|
47
|
+
const globalService = new services.ItemsService("global", { database, schema });
|
|
48
|
+
const globalSettings = await globalService.readSingleton({
|
|
49
|
+
fields: ["error_notice_recipient.directus_users_id.id"],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
recipients = extractRecipientIds(globalSettings?.error_notice_recipient);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (recipients.length === 0) {
|
|
56
|
+
this.context.logger.warn({
|
|
57
|
+
msg: `[${this.extension}] No notification recipients defined (override or global settings)`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const notificationService = new services.NotificationsService({ schema });
|
|
63
|
+
|
|
64
|
+
const now = new Date();
|
|
65
|
+
const timestamp = new Intl.DateTimeFormat("en-US", {
|
|
66
|
+
month: "2-digit",
|
|
67
|
+
day: "2-digit",
|
|
68
|
+
year: "numeric",
|
|
69
|
+
hour: "2-digit",
|
|
70
|
+
minute: "2-digit",
|
|
71
|
+
hour12: false,
|
|
72
|
+
timeZone: "UTC",
|
|
73
|
+
}).format(now);
|
|
74
|
+
|
|
75
|
+
const resolvedSubject = subject
|
|
76
|
+
? `${subject} - ${meta.projectName}`
|
|
77
|
+
: `Directus Error Notification - ${meta.projectName}`;
|
|
78
|
+
|
|
79
|
+
const fullMessage = [
|
|
80
|
+
message,
|
|
81
|
+
`<strong>Environment:</strong> ${meta.environment}`,
|
|
82
|
+
`<strong>Backend URL:</strong> <a href="${meta.backendUrl}" target="_blank">${meta.backendUrl}</a>`,
|
|
83
|
+
`<strong>Date/Time (UTC):</strong> ${timestamp}`,
|
|
84
|
+
].join("<br><br>");
|
|
85
|
+
|
|
86
|
+
for (const recipient of recipients) {
|
|
87
|
+
await notificationService.createOne({
|
|
88
|
+
recipient,
|
|
89
|
+
sender: recipient,
|
|
90
|
+
subject: resolvedSubject,
|
|
91
|
+
message: fullMessage,
|
|
92
|
+
collection,
|
|
93
|
+
item,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.context.logger.error({ msg: "❌ Failed to send Directus notification", error });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ApiExtensionContext, LogEntry, PrimaryKey, ProjectMeta } from "../types";
|
|
2
|
+
import { SlackNotifier } from "../services/SlackNotifier";
|
|
3
|
+
import { DirectusNotifier } from "./DirectusNotifier";
|
|
4
|
+
|
|
5
|
+
export class Logs {
|
|
6
|
+
protected readonly slack: SlackNotifier;
|
|
7
|
+
protected readonly directus: DirectusNotifier;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
protected readonly context: ApiExtensionContext,
|
|
11
|
+
protected readonly extension: string,
|
|
12
|
+
protected readonly collectionName: string = "logs",
|
|
13
|
+
) {
|
|
14
|
+
this.slack = new SlackNotifier(context, extension);
|
|
15
|
+
this.directus = new DirectusNotifier(context, extension);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private async getSchema() {
|
|
19
|
+
return this.context.getSchema();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private async getProjectMeta(): Promise<ProjectMeta> {
|
|
23
|
+
const settings = await this.context.database.select("project_name").from("directus_settings").first();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
projectName: settings?.project_name ?? "Unknown Project",
|
|
27
|
+
backendUrl: process.env.BACKEND_URL ?? this.context.env?.PUBLIC_URL ?? "Unknown URL",
|
|
28
|
+
environment: process.env.BRANCH ?? "dev",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async createOne(data: Record<string, any>) {
|
|
33
|
+
const schema = await this.getSchema();
|
|
34
|
+
|
|
35
|
+
const itemsService = new this.context.services.ItemsService(this.collectionName, {
|
|
36
|
+
database: this.context.database,
|
|
37
|
+
schema,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return await itemsService.createOne(data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persists an error entry to the logs collection, optionally notifying via Slack.
|
|
45
|
+
*/
|
|
46
|
+
async printLogs(functionName: string, error: string, notifySlack = true): Promise<void> {
|
|
47
|
+
const data: LogEntry = {
|
|
48
|
+
collection: this.collectionName,
|
|
49
|
+
date_created: new Date().toISOString(),
|
|
50
|
+
extension: this.extension,
|
|
51
|
+
function_name: functionName,
|
|
52
|
+
error,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await this.createOne(data);
|
|
57
|
+
|
|
58
|
+
this.context.logger.info({ msg: `🚀 [${this.extension}] ${functionName}:`, error });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
this.context.logger.error({ msg: "❌ Failed to save log entry", error: err });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (notifySlack) {
|
|
66
|
+
const meta = await this.getProjectMeta();
|
|
67
|
+
await this.slack.notify(`*Function:* ${functionName}\n*Error:* ${error}`, "Extension Error", meta);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
this.context.logger.error({ msg: "Slack failed", err });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async createActivity(action: string, collection: string, id: PrimaryKey): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const schema = await this.getSchema();
|
|
77
|
+
const { services, database } = this.context;
|
|
78
|
+
const accountability = services.accountability;
|
|
79
|
+
|
|
80
|
+
const activityService = new services.ActivityService({
|
|
81
|
+
schema,
|
|
82
|
+
accountability,
|
|
83
|
+
knex: database,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await activityService.createOne({
|
|
87
|
+
action,
|
|
88
|
+
user: accountability?.user ?? null,
|
|
89
|
+
collection,
|
|
90
|
+
ip: accountability?.ip ?? null,
|
|
91
|
+
user_agent: accountability?.userAgent ?? null,
|
|
92
|
+
origin: accountability?.origin ?? null,
|
|
93
|
+
item: id,
|
|
94
|
+
});
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.context.logger.error({ msg: "❌ Failed to create activity log", error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async createNotification(
|
|
101
|
+
message: string,
|
|
102
|
+
customSubject: string | null = null,
|
|
103
|
+
recipientOverride: string | null = null,
|
|
104
|
+
collection: string | null = null,
|
|
105
|
+
item: string | null = null,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const meta = await this.getProjectMeta();
|
|
108
|
+
|
|
109
|
+
await this.directus.notify(message, meta, {
|
|
110
|
+
subject: customSubject,
|
|
111
|
+
recipientOverride,
|
|
112
|
+
collection,
|
|
113
|
+
item,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async createSlackNotification(message: string, customSubject: string | null = null): Promise<void> {
|
|
118
|
+
const meta = await this.getProjectMeta();
|
|
119
|
+
|
|
120
|
+
await this.slack.notify(message, customSubject, meta);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ApiExtensionContext, ProjectMeta, SlackPayload } from "../types";
|
|
2
|
+
|
|
3
|
+
export class SlackNotifier {
|
|
4
|
+
constructor(
|
|
5
|
+
private readonly context: ApiExtensionContext,
|
|
6
|
+
private readonly extension: string,
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
async getWebHookUrl() {
|
|
10
|
+
const settings = await this.context.database.select("slack_webhook_url").from("global").first();
|
|
11
|
+
|
|
12
|
+
return settings?.slack_webhook_url;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async send(payload: SlackPayload): Promise<void> {
|
|
16
|
+
const webhook = await this.getWebHookUrl();
|
|
17
|
+
|
|
18
|
+
if (!webhook) {
|
|
19
|
+
this.context.logger.info({ msg: "⚠️ SLACK_WEBHOOK_URL is not configured" });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await fetch(webhook, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify(payload),
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.context.logger.error({ msg: "❌ Failed to send Slack message", error });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async notify(message: string, subject: string | null = null, meta: ProjectMeta): Promise<void> {
|
|
35
|
+
const title = subject ?? "Directus Error Notification";
|
|
36
|
+
|
|
37
|
+
await this.send({
|
|
38
|
+
text: title,
|
|
39
|
+
blocks: [
|
|
40
|
+
{
|
|
41
|
+
type: "header",
|
|
42
|
+
text: { type: "plain_text", text: `🚨 ${title}` },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "section",
|
|
46
|
+
fields: [
|
|
47
|
+
{ type: "mrkdwn", text: `*Project*\n${meta.projectName}` },
|
|
48
|
+
{ type: "mrkdwn", text: `*Environment*\n${meta.environment}` },
|
|
49
|
+
{ type: "mrkdwn", text: `*Extension*\n${this.extension}` },
|
|
50
|
+
{ type: "mrkdwn", text: `*Backend*\n${meta.backendUrl}` },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{ type: "divider" },
|
|
54
|
+
{
|
|
55
|
+
type: "section",
|
|
56
|
+
text: { type: "mrkdwn", text: message },
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ApiExtensionContext } from "@directus/extensions";
|
|
2
|
+
import type { PrimaryKey } from "@directus/types";
|
|
3
|
+
|
|
4
|
+
export interface LogEntry {
|
|
5
|
+
collection: string;
|
|
6
|
+
date_created: string;
|
|
7
|
+
extension: string;
|
|
8
|
+
function_name: string;
|
|
9
|
+
error: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RecipientLinkRow {
|
|
13
|
+
directus_users_id?: string | { id?: string | null } | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProjectMeta {
|
|
17
|
+
projectName: string;
|
|
18
|
+
backendUrl: string;
|
|
19
|
+
environment: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SlackBlock {
|
|
23
|
+
type: string;
|
|
24
|
+
text?: { type: string; text: string; emoji?: boolean };
|
|
25
|
+
fields?: { type: string; text: string }[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SlackPayload {
|
|
29
|
+
text: string;
|
|
30
|
+
blocks?: SlackBlock[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type { ApiExtensionContext };
|
|
34
|
+
|
|
35
|
+
export type { PrimaryKey };
|