Skip to content

Commit

Permalink
Python 3.12 support, adapt to Headers withut pop
Browse files Browse the repository at this point in the history
  • Loading branch information
KerekesDavid committed Sep 12, 2024
1 parent 06dcb2c commit e1f1e52
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 80 deletions.
197 changes: 120 additions & 77 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,52 @@
# Pycolytics
A tiny webservice for logging software analytics events. It takes HTTP requests, and puts them into an SQLite database,

The goal of this library is to be easy to set up and easy to use. Minimal dependencies, no complicated database setups.
A tiny webservice for logging software analytics events.
It takes HTTP requests, and puts them into an SQLite database.

- Easy setup on any linux machine, or in wsl:
The goal of this library is to be easy to set up and easy to use.
Minimal dependencies, no complicated database setups.

```
git clone git@github.com:KerekesDavid/pycolytics.git
cd pycolytics
pip install -r requirements.txt
```
- Easy setup on any linux machine, or in WSL:

- Launches out of the box:
```
fastapi dev
```
- After launch, API docs are available at: http://127.0.0.1:8000/docs
## How We Got Here
```sh
git clone git@github.com:KerekesDavid/pycolytics.git
cd pycolytics
pip install -r requirements.txt
```

Pycolytics is written in python, based on [SQLite](https://github.com/sqlite/sqlite) and [FastAPI](https://github.com/fastapi/fastapi), and was inspired by [Attolytics](https://github.com/ttencate/attolytics/).
When I was looking at Attolytics, I was too lazy to set up a rust compile environment and install postgresql for something so simple, so I spent two days writing Pycolytics instead. To help you avoid my mistake, I made it so you can just clone it and move on with your life.
- Launches out of the box:

True to its name, Pycolytics is probably 10<sup>6</sup> times slower than Attolytics, but who cares if it still serves my entire userbase from a rasberry-pi. It does asyncio and fancy multi-worker stuff to try and compensate.
```sh
fastapi dev
```

Open an issue if you wish to contribute, or buy me a coffee if you find my work useful.
<a href='https://ko-fi.com/E1E712JJXK' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
- After launch, API docs are available at: <http://127.0.0.1:8000/docs>

Pycolytics is written in python, based on
[SQLite](https://github.com/sqlite/sqlite) and
[FastAPI](https://github.com/fastapi/fastapi), and was inspired by
[Attolytics](https://github.com/ttencate/attolytics/).

## Client Plugin for Godot 4.2+
- I wrote a plugin for Godot: just install it and call a single function to log an event! You can find it in the [asset library](https://godotengine.org/asset-library/asset/3292), or on [github](https://github.com/KerekesDavid/pycolytics-godot).

- If you have written clients for anything else, I would be more than happy to feature them here!
- I wrote a plugin for Godot: just install it and call a single function to log
an event! You can find it in the
[asset library](https://godotengine.org/asset-library/asset/3292), or on
[github](https://github.com/KerekesDavid/pycolytics-godot).

- If you have written clients for anything else, I would be more than happy to
feature them here!

## Configuration

Edit the .env file, or specify these parameters as environment variables:
```

```sh
# Name of the database file to write into.
SQLITE_FILE_PATH="databases/database.db"

# A list of secret keys. The server won't accept events that do not contain one of these keys in the request body.
# A list of secret keys. The server won't accept events
# that do not contain one of these keys in the request body.
API_KEYS=["I-am-an-unsecure-dev-key-REPLACE_ME"]

# Requests from the same IP above this rate will be rejected.
Expand All @@ -52,9 +55,11 @@ RATE_LIMIT="60/minute"
```

## API
The server will listen to POST requests at `http://ip:port/v1.0/event`, and will expect a request body in the following format:

```
The server will listen to POST requests at `http://ip:port/v1.0/event`,
and will expect a request body in the following format:

```json
{
"event_type": "string",
"application": "string",
Expand All @@ -65,23 +70,25 @@ The server will listen to POST requests at `http://ip:port/v1.0/event`, and will
"value": {
"event_description": "Life, the universe and everything.",
"event_data": 42
},
},
"api_key": "I-am-an-unsecure-dev-key-REPLACE_ME"
}
```

There is also a more performant batch interface at `http://ip:port/v1.0/events`, expecting a list of events:
```
There is also a more performant batch interface at
`http://ip:port/v1.0/events`, expecting a list of events:

```json
[
{"event_type": ...},
{"event_type": ...},
{"event_type": ...},
...
]
```

An example curl call for logging an event:

```
```sh
curl -X 'POST' \
'http://127.0.0.1:8000/v1.0/event' \
-H 'accept: */*' \
Expand All @@ -106,9 +113,11 @@ The `value` field can contain an arbitrary JSON with event details.
The POST request will return `204: No Content` on successful inserts.

## Database

The database will contain an `event` table with all logged events.
The columns are:
```

```sql
event_type VARCHAR NOT NULL
platform VARCHAR NOT NULL
version VARCHAR NOT NULL
Expand All @@ -119,82 +128,116 @@ id INTEGER NOT NULL, PRIMARY KEY
time DATETIME NOT NULL
```

It can be opened using any sqlite database browser, or in python using the built in sqlite package.
It can be opened using any sqlite database browser,
or in python using the built in sqlite package.

My personal choice for performing data analytics is a [jupyter notebook](https://jupyter.org/) using [pandas](https://pandas.pydata.org/). They have a wonerful cheat sheet [here](https://pandas.pydata.org/Pandas_Cheat_Sheet.pdf).
My personal choice for performing data analytics is a
[jupyter notebook](https://jupyter.org/) using
[pandas](https://pandas.pydata.org/). They have a wonderful cheat sheet
[here](https://pandas.pydata.org/Pandas_Cheat_Sheet.pdf).

## Launching a Production Server

Setting up a permanent server as a service is also quite simple.

The method I share here has some minimal extra complications, but it ensures some level of separation from other parts of the system using systemd's `DynamicUser` parameter. It might come handy in case there is a vulnerability in FastAPI.
The method I share here has some minimal extra complications, but it ensures
some level of separation from other parts of the system using systemd's
`DynamicUser` parameter. It might come handy in case there is a vulnerability
in FastAPI.

(Contributions to this section are very welcome, I'm barely a fledgeling server admin.)
(Contributions to this section are very welcome, I'm barely a fledgelig server admin.)

- Install pycolytics, and set up a virtualenv.
A usual place for this would be `/srv/pycolytics` for example.

- Create a systemd service file: `/etc/systemd/system/pycolytics.service`

```
[Unit]
Description=Uvicorn instance serving Pycolytics
After=network.target
```INI
[Unit]
Description=Uvicorn instance serving Pycolytics
After=network.target

[Service]
Type=simple
DynamicUser=yes
User=pycolytics
[Service]
Type=simple
DynamicUser=yes
User=pycolytics

WorkingDirectory=/srv/pycolytics
StateDirectory=pycolytics/databases
WorkingDirectory=/srv/pycolytics
StateDirectory=pycolytics/databases

ExecStart=/srv/pycolytics/.venv/bin/uvicorn \
--workers=4 \
--host=0.0.0.0 \
--port=8080 \
app.main:app
ExecReload=/bin/kill -HUP ${MAINPID}
RestartSec=15
Restart=always
ExecStart=/srv/pycolytics/.venv/bin/uvicorn \
--workers=4 \
--host=0.0.0.0 \
--port=8080 \
app.main:app
ExecReload=/bin/kill -HUP ${MAINPID}
RestartSec=15
Restart=always

[Install]
WantedBy=multi-user.target
[Install]
WantedBy=multi-user.target

```

```
- Generate an API key:
```
openssl rand -base64 24
```
This will stop random people from logging events in your database, it will not stop a script kiddie who can decompile the key from your app, or pluck it from network traffic. I'd suggest creating a new one for every version of your application, and retiring old ones after a while.

- Setup the .env file:
- Replace `API_KEYS=["I-am-an-unsecure-dev-key-REPLACE_ME"]` with the newly generated key.
- Set the database path to the systemd state directory: `SQLITE_FILE_NAME="/var/lib/pycolytics/databases/database.db"`
```sh
openssl rand -base64 24
```

This will stop random people from logging events in your database, it will
not stop a script kiddie who can decompile the key from your app, or pluck it
from network traffic. I'd suggest creating a new one for every version of
your application, and retiring old ones after a while.

- Set up the .env file:

- Replace `API_KEYS=["I-am-an-unsecure-dev-key-REPLACE_ME"]` with the
newly generated key.
- Set the database path to the systemd state directory:
`SQLITE_FILE_NAME="/var/lib/pycolytics/databases/database.db"`

- Run read the new config:

```sudo systemctl daemon-reload```
`sudo systemctl daemon-reload`

- Make the service start on boot:

```sudo systemctl enable pycolytics```
`sudo systemctl enable pycolytics`

- Start the service:

```sudo systemctl start pycolytics```
`sudo systemctl start pycolytics`

- Check for errors:

```sudo systemctl status pycolytics```
`sudo systemctl status pycolytics`

- In case you need to fix configurations and restart the service use:

```sudo systemctl daemon-reload
sudo systemctl restart pycolytics
```
```sudo systemctl daemon-reload
sudo systemctl restart pycolytics
```

Most online guides also recommend setting up fastapi behind an nginx reverse proxy, in case somebody tries to DDOS your server. I've never been successful enough for this to happen, so I'll leave it to you to figure out the details.
Most online guides also recommend setting up fastapi behind an nginx reverse
proxy, in case somebody tries to DDOS your server. I've never been successful
enough for this to happen, so I'll leave it to you to figure out the details.

## How We Got Here

When I was looking at Attolytics, I was too lazy to set up a rust compile
environment and install postgresql for something so simple, so I spent two days
writing Pycolytics instead. To help you avoid my mistake, I made it so you can
just clone it and move on with your life.

True to its name, Pycolytics is probably 10⁶ times slower than Attolytics, but
who cares if it still serves my entire userbase from a raspberry-pi. It does
asyncio and fancy multi-worker stuff to try and compensate.

Open an issue if you wish to contribute, or buy me a coffee if you find my work useful.

<a href='https://ko-fi.com/E1E712JJXK' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

## Planned Features
- HTTPS communication for you security nerds out there

- HTTPS communication for you security nerds out there
11 changes: 8 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "v1.1.0"
__version__ = "v1.1.1"
__api_version__ = "v1.0.0"

from contextlib import asynccontextmanager
Expand Down Expand Up @@ -27,6 +27,7 @@ async def lifespan(app: fastapi.FastAPI):
await create_db_and_tables()
yield


description = f"""
Running Pycolitics __{__version__}__
Expand All @@ -53,10 +54,12 @@ async def log_event(
session: AsyncSession = fastapi.Depends(get_session),
event: EventCreate,
request: fastapi.Request,
response: fastapi.Response,
):
db_event = Event.model_validate(event)
session.add(db_event)
await session.commit()
del response.headers["content-type"]


@app.post("/v1.0/events", status_code=204, responses={401: {"model": HTTPError}})
Expand All @@ -66,8 +69,10 @@ async def log_events(
session: AsyncSession = fastapi.Depends(get_session),
events: list[EventCreate],
request: fastapi.Request,
response: fastapi.Response,
):
db_events = [Event.model_validate(event).model_dump() for event in events]
# Pylance freaks out if I use exec here, says it can't take an Executable
await session.execute(sqlmodel.insert(Event).values(db_events))
# Pyright freaks out here, claims this can't take an Executable when it clearly can
await session.exec(sqlmodel.insert(Event).values(db_events))
await session.commit()
del response.headers["content-type"]

0 comments on commit e1f1e52

Please sign in to comment.